@namewta/speculo 1.0.12 → 1.0.14

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.
Files changed (50) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/template/canonical/canonical-specdev-goal-plan.md +8 -5
  4. package/template/canonical/canonical-specdev-grill-with-docs.md +5 -4
  5. package/template/canonical/canonical-specdev-spec.md +7 -4
  6. package/template/canonical/canonical-specdev-tickets.md +19 -4
  7. package/template/commands/handoff.md +1 -1
  8. package/template/commands/status.md +1 -1
  9. package/template/workflows/ops/H-host-manage/H-host-manage.md +3 -0
  10. package/template/workflows/ops/INDEX.md +1 -1
  11. package/template/workflows/ops/README.md +1 -1
  12. package/template/workflows/ops/common/CAPABILITIES.md +2 -2
  13. package/template/workflows/ops/common/USAGE.md +23 -4
  14. package/template/workflows/ops/common/examples/register.example.json +1 -1
  15. package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +522 -0
  16. package/template/workflows/ops/common/toolchains/volta-linux.json +35 -0
  17. package/template/workflows/ops/common/tools/bootstrap-volta.sh +210 -0
  18. package/template/workflows/ops/common/tools/bootstrap.sh +1 -0
  19. package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +221 -0
  20. package/template/workflows/ops/common/tools/opslib/cli.mjs +27 -5
  21. package/template/workflows/ops/common/tools/opslib/core.mjs +12 -0
  22. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +20 -4
  23. package/template/workflows/ops/common/tools/opslib/transport.mjs +101 -22
  24. package/template/workflows/ops/common/tools/validate-ops.mjs +1 -0
  25. package/template/workflows/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md +3 -3
  26. package/template/workflows/specdev/I-implement/references/implementation-procedure.md +1 -1
  27. package/template/workflows/specdev/I-init-setup/tracking-template.md +1 -1
  28. package/template/workflows/specdev/INDEX.md +1 -1
  29. package/template/workflows/specdev/README.md +4 -4
  30. package/template/workflows/specdev/T-tickets/ticket-template.md +1 -0
  31. package/template/workflows/specdev/T-triage/T-triage.md +44 -13
  32. package/template/workflows/specdev/T-triage/close-comment-template.md +42 -0
  33. package/template/workflows/specdev/T-triage/intake-protocol.md +6 -2
  34. package/template/workflows/specdev/T-triage/issue-body-template.md +34 -0
  35. package/template/workflows/specdev/T-triage/publish-protocol.md +92 -0
  36. package/template/workflows/specdev/T-triage/publish-template.md +49 -0
  37. package/template/workflows/specdev/T-triage/reconcile-protocol.md +3 -3
  38. package/template/workflows/specdev/T-triage/references/classification-map.md +53 -0
  39. package/template/workflows/specdev/T-triage/references/public-projection.md +66 -0
  40. package/template/workflows/specdev/T-triage/tools/publish-status.mjs +148 -0
  41. package/template/workflows/specdev/T-triage/triage-template.md +12 -1
  42. package/template/workflows/specdev/common/README.md +1 -0
  43. package/template/workflows/specdev/common/rules/artifact-contract.md +5 -4
  44. package/template/workflows/specdev/common/rules/change-completion.md +1 -1
  45. package/template/workflows/specdev/common/rules/evidence-and-verification.md +2 -0
  46. package/template/workflows/specdev/common/rules/workflow-routing.md +2 -1
  47. package/template/workflows/specdev/common/schemas/publish.schema.json +21 -0
  48. package/template/workflows/specdev/common/schemas/ticket.schema.json +11 -0
  49. package/template/workflows/specdev/common/schemas/triage.schema.json +3 -1
  50. package/template/workflows/specdev/common/tools/validate-specdev.mjs +173 -3
@@ -1,7 +1,7 @@
1
1
  /** Strict host-key SSH and subprocess local transport. No passwords in argv. */
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { readFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { canonical, digest, noSymlinks, OpsError, redact, UnknownResult } from "./core.mjs";
7
7
  import { fingerprint } from "./agent.mjs";
@@ -30,6 +30,102 @@ export function hostTransportDigest(host) {
30
30
  return digest({ host, known_hosts_digest: kh });
31
31
  }
32
32
 
33
+ export function validateSshEndpoint(c) {
34
+ if (!c || typeof c !== "object") throw new OpsError("ssh endpoint missing");
35
+ for (const k of ["hostname", "username", "known_hosts"]) {
36
+ if (!c[k]) throw new OpsError("ssh connection missing " + k);
37
+ }
38
+ if (!/^[A-Za-z0-9_.:-]+$/.test(c.hostname) || c.hostname.startsWith("-")) throw new OpsError("unsafe SSH hostname");
39
+ if (!/^[A-Za-z0-9_.-]+$/.test(c.username) || c.username.startsWith("-")) throw new OpsError("unsafe SSH username");
40
+ if (![undefined, "posix", "powershell"].includes(c.shell)) throw new OpsError("unsupported remote shell");
41
+ }
42
+
43
+ export function sshArgv(connection, { scp = false } = {}) {
44
+ validateSshEndpoint(connection);
45
+ const kh = resolve(connection.known_hosts);
46
+ noSymlinks(kh, { allowMissing: false });
47
+ const argv = [
48
+ scp ? "scp" : "ssh",
49
+ ...(scp ? [] : ["-T"]),
50
+ "-o", "BatchMode=yes",
51
+ "-o", "StrictHostKeyChecking=yes",
52
+ "-o", `UserKnownHostsFile=${kh}`,
53
+ "-o", "ConnectTimeout=15",
54
+ "-o", "ServerAliveInterval=15",
55
+ "-o", "ServerAliveCountMax=3",
56
+ scp ? "-P" : "-p", String(connection.port ?? 22),
57
+ ];
58
+ if (connection.identity_file) argv.push("-i", connection.identity_file, "-o", "IdentitiesOnly=yes");
59
+ return argv;
60
+ }
61
+
62
+ function spawnTransport(argv, { input, timeout, encoding = "buffer" } = {}) {
63
+ return transportHooks.spawnSync(argv[0], argv.slice(1), {
64
+ input,
65
+ encoding,
66
+ timeout: timeout * 1000,
67
+ maxBuffer: 64 * 1024 * 1024,
68
+ windowsHide: true,
69
+ });
70
+ }
71
+
72
+ function buffers(p) {
73
+ const stdout = Buffer.isBuffer(p.stdout) ? p.stdout.toString("utf8") : (p.stdout || "");
74
+ const stderr = Buffer.isBuffer(p.stderr) ? p.stderr.toString("utf8") : (p.stderr || "");
75
+ return { stdout, stderr };
76
+ }
77
+
78
+ export function posixCall(endpoint, script, { timeout = 180, args = [], sudo = false } = {}) {
79
+ validateSshEndpoint(endpoint);
80
+ if ((endpoint.shell ?? "posix") === "powershell") {
81
+ throw new OpsError("POSIX bootstrap is Linux-only; PowerShell targets still require an existing Node");
82
+ }
83
+ const useSudo = sudo || endpoint.sudo;
84
+ let remote = ["/bin/sh", "-s", "--", ...args.map(String)];
85
+ if (useSudo) remote = ["sudo", "-n", "--", ...remote];
86
+ const argv = sshArgv(endpoint);
87
+ argv.push("--", endpoint.username + "@" + endpoint.hostname, shlexJoin(remote));
88
+ let p;
89
+ try {
90
+ p = spawnTransport(argv, { input: Buffer.from(String(script), "utf8"), timeout });
91
+ } catch (e) {
92
+ throw new OpsError("target posix unavailable: " + e);
93
+ }
94
+ if (p.error && (p.error.code === "ETIMEDOUT" || p.signal === "SIGTERM")) {
95
+ throw new OpsError("target posix unavailable: " + p.error);
96
+ }
97
+ const { stdout, stderr } = buffers(p);
98
+ if (p.status !== 0) {
99
+ throw new OpsError("target posix failed: " + redact(stderr, []).slice(-2000));
100
+ }
101
+ return { status: p.status, stdout, stderr };
102
+ }
103
+
104
+ export function posixSend(endpoint, localFile, remotePath, { timeout = 180 } = {}) {
105
+ validateSshEndpoint(endpoint);
106
+ if ((endpoint.shell ?? "posix") === "powershell") {
107
+ throw new OpsError("POSIX bootstrap is Linux-only; PowerShell targets still require an existing Node");
108
+ }
109
+ noSymlinks(localFile, { allowMissing: false });
110
+ if (typeof remotePath !== "string" || !isAbsolute(remotePath) || remotePath.split("/").includes("..") || /[\s\n\r]/.test(remotePath)) {
111
+ throw new OpsError("remote scp path must be absolute without spaces or ..");
112
+ }
113
+ const argv = sshArgv(endpoint, { scp: true });
114
+ argv.push("--", localFile, `${endpoint.username}@${endpoint.hostname}:${remotePath}`);
115
+ let p;
116
+ try {
117
+ p = spawnTransport(argv, { input: Buffer.alloc(0), timeout });
118
+ } catch (e) {
119
+ throw new OpsError("target scp unavailable: " + e);
120
+ }
121
+ if (p.error && (p.error.code === "ETIMEDOUT" || p.signal === "SIGTERM")) {
122
+ throw new OpsError("target scp unavailable: " + p.error);
123
+ }
124
+ const { stderr } = buffers(p);
125
+ if (p.status !== 0) throw new OpsError("target scp failed: " + redact(stderr, []).slice(-2000));
126
+ return { status: 0, remotePath };
127
+ }
128
+
33
129
  function injectRequest(source, request) {
34
130
  const b64 = Buffer.from(canonical(request)).toString("base64");
35
131
  const assign = `globalThis.OPS_REQUEST = JSON.parse(Buffer.from(${JSON.stringify(b64)}, "base64").toString("utf8"));`;
@@ -49,13 +145,7 @@ export function call(host, request, { timeout = 1800 } = {}) {
49
145
  const argv = [process.execPath, "--input-type=module"];
50
146
  let p;
51
147
  try {
52
- p = transportHooks.spawnSync(argv[0], argv.slice(1), {
53
- input: content,
54
- encoding: "utf8",
55
- timeout: timeout * 1000,
56
- maxBuffer: 64 * 1024 * 1024,
57
- windowsHide: true,
58
- });
148
+ p = spawnTransport(argv, { input: content, timeout, encoding: "utf8" });
59
149
  } catch (e) {
60
150
  if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport interrupted; inspect receipts before retry");
61
151
  throw new OpsError("target read unavailable: " + e);
@@ -63,11 +153,7 @@ export function call(host, request, { timeout = 1800 } = {}) {
63
153
  return decode(p, request);
64
154
  }
65
155
  const c = host.connection;
66
- const kh = resolve(c.known_hosts);
67
- noSymlinks(kh, { allowMissing: false });
68
- const argv = ["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${kh}`,
69
- "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", "-p", String(c.port ?? 22)];
70
- if (c.identity_file) argv.push("-i", c.identity_file, "-o", "IdentitiesOnly=yes");
156
+ const argv = sshArgv(c);
71
157
  let remote = [c.node, "--input-type=module"];
72
158
  if (c.sudo) remote = ["sudo", "-n", "--", ...remote];
73
159
  let remoteCommand;
@@ -82,13 +168,7 @@ export function call(host, request, { timeout = 1800 } = {}) {
82
168
  argv.push("--", c.username + "@" + c.hostname, remoteCommand);
83
169
  let p;
84
170
  try {
85
- p = transportHooks.spawnSync(argv[0], argv.slice(1), {
86
- input: Buffer.from(content, "utf8"),
87
- encoding: "buffer",
88
- timeout: timeout * 1000,
89
- maxBuffer: 64 * 1024 * 1024,
90
- windowsHide: true,
91
- });
171
+ p = spawnTransport(argv, { input: Buffer.from(content, "utf8"), timeout });
92
172
  } catch (e) {
93
173
  if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport interrupted; inspect receipts before retry");
94
174
  throw new OpsError("target read unavailable: " + e);
@@ -101,8 +181,7 @@ function decode(p, request) {
101
181
  if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport interrupted; inspect receipts before retry");
102
182
  throw new OpsError("target read unavailable: " + p.error);
103
183
  }
104
- const stdout = Buffer.isBuffer(p.stdout) ? p.stdout.toString("utf8") : (p.stdout || "");
105
- const stderr = Buffer.isBuffer(p.stderr) ? p.stderr.toString("utf8") : (p.stderr || "");
184
+ const { stdout, stderr } = buffers(p);
106
185
  if (p.status !== 0) {
107
186
  const text = redact(stderr, request.secrets || []).slice(-2000);
108
187
  if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport failed: " + text);
@@ -27,6 +27,7 @@ try {
27
27
  for (const name of readdirSync(join(root, "common/schemas")).filter((x) => x.endsWith(".json"))) {
28
28
  JSON.parse(readFileSync(join(root, "common/schemas", name), "utf8"));
29
29
  }
30
+ JSON.parse(readFileSync(join(root, "common/toolchains/volta-linux.json"), "utf8"));
30
31
  validateStatus(JSON.parse(readFileSync(join(root, "_state/status.json"), "utf8")));
31
32
  console.log("resource schema valid");
32
33
  console.log(JSON.stringify({
@@ -3,7 +3,7 @@ id: specdev/archive-and-consolidate
3
3
  type: workflow-entry
4
4
  workflow: specdev
5
5
  name: 归档与沉淀
6
- description: 校验本地完成与远程 reconcile 门,复用全局归档能力移动 completed change 并提升当前知识,或从代码访谈形成可归档知识 change。
6
+ description: 校验本地完成、源 Issue reconcile 门和票级 publish_action 门,复用全局归档能力移动 completed change 并提升当前知识,或从代码访谈形成可归档知识 change。
7
7
  keywords: [归档, consolidation, ADR, context, research, knowledge, 代码库访谈]
8
8
  ---
9
9
 
@@ -31,7 +31,7 @@ A 是 SpecDev 的归档 wrapper:它拥有模式选择、SpecDev 完成门和
31
31
 
32
32
  1. 读取全局/change 状态、Ticket、Map、Goal Plan、Evidence、ADR、CONTEXT、LOG、triage 和项目验证事实。
33
33
  2. 加载 `<Path>{roots.workflows}/specdev/common/rules/change-completion.md</Path>` 与 `<Path>{roots.workflows}/specdev/common/rules/parent-implementation-orchestration.md</Path>`,确认 `change_status: completed`、完成 owner 已写入时间和证据、无 blocker/deviation;若该 change 是未完成父实现 change 的成员则停止,若其自身是父实现 change 则还需所有成员与 aggregate Evidence 完成。
34
- 3. 检查 `<Path>{roots.state}/specdev/changes/{change}/triage.md</Path>` 的 `external_action`:`pending-close` 或 `close-failed` 返回 `<Path>{roots.workflows}/specdev/T-triage/T-triage.md</Path>`;只有 `closed | waived | not-applicable` 继续。
34
+ 3. 检查 `<Path>{roots.state}/specdev/changes/{change}/triage.md</Path>` 的 `external_action`:`pending-close` 或 `close-failed` 返回 `<Path>{roots.workflows}/specdev/T-triage/T-triage.md</Path>`;只有 `closed | waived | not-applicable` 继续。再检查 `publish_action`:`pending` 或 `publish-failed` 同样返回 T-triage publish;只有 `not-requested | published | waived`(缺省视为 `not-requested`)继续。
35
35
  4. 调用 `<Path>{roots.skills}/archive-and-consolidate/SKILL.md</Path>` 的 `archive-single + dry-run`,传入已解析 workflow/state/changes/archive/knowledge roots。展示完整移动、提升和清理计划。
36
36
  5. 只有用户明确批准该计划后调用 `confirmed`。移动、知识写入和清理均使用计划内路径;计划后出现 drift 时停止。
37
37
  6. 重读源、归档目标、全局索引、归档 `<Path>{roots.state}/specdev/archive/YYYY-MM/{change}/.status.json</Path>` 和永久知识;运行 `--stage complete` 及包级校验,报告每个提升/跳过结论。
@@ -51,7 +51,7 @@ Dry-run 不修改文件。归档移动、知识 merge/rewrite/delete、Git 动
51
51
  ## 完成标准
52
52
 
53
53
  - 模式与唯一 change 已确定;
54
- - 本地完成和 external reconcile 门通过;
54
+ - 本地完成、external reconcile 门和 publish_action 门通过;
55
55
  - 机械归档与知识规则只有全局 skill 一个事实源;
56
56
  - dry-run 与 confirmed 执行严格分离;
57
57
  - 源不存在、目标完整、active/archived 无重叠、归档状态正确;
@@ -147,7 +147,7 @@ Lead 使用 `<Path>{roots.workflows}/specdev/I-implement/evidence-template.md</P
147
147
 
148
148
  Ticket 正常状态:`ready → in_progress → review → done`。`required` 的 `done` 要求 change worktree 已完成集成(`integrated` 或 `removed`)、父 HEAD=result SHA 且包含 source commit;`current` 的 `done` 要求 current workspace clean、direct-parent 验证通过且父 HEAD=result SHA。阻塞使用 `blocked`,契约偏差使用 `deviated`,无需改动使用 `cancelled`。Direct Spec 由当前 I-implement owner 按 `<Path>{roots.workflows}/specdev/common/rules/change-completion.md</Path>` 关闭 change。
149
149
 
150
- 按存在和当前模式同步 Ticket、Tickets Map、Goal Plan、`<Path>{roots.state}/specdev/changes/{change}/.status.json</Path>` 和全局状态;Direct Spec 不创建缺失的 Ticket/Map/Goal Plan。最后一个计划内 Ticket 完成后,Goal Plan 的 Lead 按 change completion 关闭;无 Goal Plan 的当前 I owner 承担同一门禁。需要远程 reconcile 时返回 T-triage,否则进入 Archive。
150
+ 按存在和当前模式同步 Ticket、Tickets Map、Goal Plan、`<Path>{roots.state}/specdev/changes/{change}/.status.json</Path>` 和全局状态;Direct Spec 不创建缺失的 Ticket/Map/Goal Plan。最后一个计划内 Ticket 完成后,Goal Plan 的 Lead 按 change completion 关闭;无 Goal Plan 的当前 I owner 承担同一门禁。需要远程 reconcile 或把已完成 Ticket 投影为 GitHub Issue 时返回 T-triage,否则进入 Archive。
151
151
 
152
152
  当前 change 属于未完成父实现 change 时,单个组合 Ticket 完成、阻塞或触发 Lead 复盘,且子状态与 Evidence 已写入后,必须自动返回 `<Path>{roots.workflows}/specdev/P-goal-plan/P-goal-plan.md</Path>`,由父 Lead 重读全部成员并决定重新派发、返回上游或继续下一 frontier;不得要求用户逐个重新激活,不得直接归档子 change,也不得从本 Work 实现另一个成员。
153
153
 
@@ -10,4 +10,4 @@ SpecDev 只使用本地 Markdown/JSON 工件跟踪开发。远程 Issue、URL
10
10
  - 工件状态应在同一次操作中同步,避免入口状态、Ticket 状态与 Map 状态漂移。
11
11
  - 完成条件:全部必需 Ticket 为 `done` 或有批准的 `cancelled`,证据齐全,无未批准 deviation,change 级验证通过。
12
12
  - 归档后的 `<Path>{roots.state}/specdev/archive/YYYY-MM/{change}/</Path>` 默认只读;后续纠正通过新 change 和 supersedes 链完成。
13
- - 可关闭的远程来源在本地完成后由 Triage reconcile;`closed`、显式 `waived` 或 `not-applicable` 后才归档。
13
+ - 可关闭的远程来源在本地完成后由 Triage reconcile;`closed`、显式 `waived` 或 `not-applicable` 后才归档。完成后若要把 Ticket 记到 GitHub,由 T-triage publish 写入 `<Path>{roots.state}/specdev/changes/{change}/publish.md</Path>`;`pending`/`publish-failed` 不可归档。发布计数以该账本为准,标签词表见 T-triage classification-map。远程 Issue 仍不是开发权威。
@@ -3,7 +3,7 @@ id: specdev
3
3
  type: workflow
4
4
  workflow: specdev
5
5
  name: SpecDev Workflow
6
- description: 以本地工件为唯一开发权威,从来源冻结、诊断、设计、原型、规格、Ticket、编排和审查推进到证据驱动实现、远程 reconcile 与知识归档。
6
+ description: 以本地工件为唯一开发权威,从来源冻结、诊断、设计、原型、规格、Ticket、编排和审查推进到证据驱动实现、远程 reconcile 或票级发布投影与知识归档。
7
7
  keywords: [specdev, local-first, 规格驱动开发, decision-complete, prototype, code-review, TDD, 证据]
8
8
  ---
9
9
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## 工件链与权威
10
10
 
11
- 大需求 → W 的 Initiative 候选图 → 每个 change 自己的 G/Grill → S/Spec → T 的计划型 Ticket 与 tickets-map → P 的 Goal → I/实现与 Evidence → Goal 集成验收 → 按需学习/远程 reconcile/归档。
11
+ 大需求 → W 的 Initiative 候选图 → 每个 change 自己的 G/Grill → S/Spec → T 的计划型 Ticket 与 tickets-map → P 的 Goal → I/实现与 Evidence → Goal 集成验收 → 按需学习/远程 reconcile 或 publish/归档。
12
12
 
13
13
  已明确的小请求直接进入适用阶段;无需为了路由而创建 Triage。跨 change 实现由 P 统一管理。
14
14
 
@@ -47,14 +47,14 @@ CLI 初始化和刷新保持原 namespace、三方配置合并、schema migrator
47
47
 
48
48
  ## 场景路由
49
49
 
50
- 不清晰的大需求选 W,单 change 的高影响决定选 G,Ready Spec 拆票选 T-tickets,一个或多个 Ready change 的 Goal 选 P-goal-plan;来源冻结/远程回写才选 T-triage。精确条件与全部出口见 `<Path>{roots.workflows}/specdev/common/rules/workflow-routing.md</Path>`。
50
+ 不清晰的大需求选 W,单 change 的高影响决定选 G,Ready Spec 拆票选 T-tickets,一个或多个 Ready change 的 Goal 选 P-goal-plan;来源冻结/远程回写/完成后记账才选 T-triage。精确条件与全部出口见 `<Path>{roots.workflows}/specdev/common/rules/workflow-routing.md</Path>`。
51
51
 
52
52
  ## Work 条目
53
53
 
54
54
 
55
55
  <!-- AUTO-INDEX-START -->
56
56
 
57
- - **A-archive-and-consolidate** — 归档与沉淀:校验本地完成与远程 reconcile 门,复用全局归档能力移动 completed change 并提升当前知识,或从代码访谈形成可归档知识 change。
57
+ - **A-archive-and-consolidate** — 归档与沉淀:校验本地完成、源 Issue reconcile 门和票级 publish_action 门,复用全局归档能力移动 completed change 并提升当前知识,或从代码访谈形成可归档知识 change。
58
58
  - **C-code-review** — 代码审查:将 commit、branch、tag、merge-base 或 PR 解析为本地不可变固定点,执行隔离的标准轴与规范轴审查并持久化可恢复报告。
59
59
  - **D-diagnose-bugs** — 诊断 Bug:先建立会在精确症状上变红的紧凑反馈回路,再通过最小化、排名假设和单变量探针确认根因,输出修复契约而不实施生产修复。
60
60
  - **G-grill-with-docs** — Change 决策访谈:一个已界定 change 仍有产品、领域或架构决定待确认时进行可恢复访谈;跨 change 边界未清晰时先用 W。
@@ -66,7 +66,7 @@ CLI 初始化和刷新保持原 namespace、三方配置合并、schema migrator
66
66
  - **R-review-architecture** — 架构审查:从用户指定范围或 Git 热点扫描代码库中的结构性坏味道、代码 judo 机会和维护性风险,以中文 Markdown 记录高置信候选,并对用户选择的一个方案运行设计树访谈。
67
67
  - **S-spec** — 编写 Spec:综合已知事实、设计决定、诊断与代码现状,产出以外部行为和验收合同为权威的 Ready Spec。
68
68
  - **T-tickets** — 编写计划型 Tickets:将已澄清的 Spec 或等价获批计划拆为可验收的实施 Ticket,并绑定真实项目 Skill;不用于探索未知需求或执行代码。
69
- - **T-triage** — 请求分诊:需要冻结外部来源、审计摄入或对 completed change 回写来源 Issue 时使用;已清晰的本地需求不必经本入口路由。
69
+ - **T-triage** — 请求分诊:需要冻结外部来源、审计摄入、对 completed change 回写来源 Issue,或把已完成 Ticket 投影为带分类标签的 GitHub Issue 时使用;已清晰的本地需求不必为了路由而经本入口。
70
70
  - **W-wayfinder** — 探索大需求与 Change 边界:大需求的 change 边界或实施路线尚不可见时建立探索地图,并分别澄清各 change;已有清晰 Spec 时不触发。
71
71
 
72
72
  <!-- AUTO-INDEX-END -->
@@ -9,6 +9,7 @@ change: <YYYY-MM-DD-topic>
9
9
  id: T-01
10
10
  title: <标题>
11
11
  status: draft
12
+ kind: bug
12
13
  planning_depth: standard
13
14
  planning_depth_reason: <触发该深度的事实>
14
15
  ready: false
@@ -3,15 +3,15 @@ id: specdev/triage
3
3
  type: workflow-entry
4
4
  workflow: specdev
5
5
  name: 请求分诊
6
- description: 需要冻结外部来源、审计摄入或对 completed change 回写来源 Issue 时使用;已清晰的本地需求不必经本入口路由。
7
- keywords: [triage, 摄入, import, issue, reconcile, close, 风险, 路由]
6
+ description: 需要冻结外部来源、审计摄入、对 completed change 回写来源 Issue,或把已完成 Ticket 投影为带分类标签的 GitHub Issue 时使用;已清晰的本地需求不必为了路由而经本入口。
7
+ keywords: [triage, 摄入, import, issue, reconcile, publish, close, 风险, 路由]
8
8
  ---
9
9
 
10
10
  # 请求分诊
11
11
 
12
12
  > 激活本 Work 后,先读取 `<Path>{roots.workflows}/specdev/README.md</Path>`,再执行本入口。
13
13
 
14
- Triage 是 SpecDev 唯一的远程摄入与关闭边界。开发期间,`<Path>{roots.state}/specdev/changes/{change}/source.md</Path>`、`<Path>{roots.state}/specdev/changes/{change}/triage.md</Path>`、Spec、Ticket、Map、Goal Plan、Evidence 和状态文件是唯一权威;远程系统只保存原始请求以及经确认后的完成通知。
14
+ Triage 是 SpecDev 唯一的远程摄入、关闭与发布投影边界。开发期间,`<Path>{roots.state}/specdev/changes/{change}/source.md</Path>`、`<Path>{roots.state}/specdev/changes/{change}/triage.md</Path>`、`<Path>{roots.state}/specdev/changes/{change}/publish.md</Path>`、Spec、Ticket、Map、Goal Plan、Evidence 和状态文件是唯一权威;远程系统只保存原始请求以及经确认后的完成通知与票级发布记录。
15
15
 
16
16
  ## 读取范围
17
17
 
@@ -19,28 +19,30 @@ Triage 是 SpecDev 唯一的远程摄入与关闭边界。开发期间,`<Path>
19
19
  2. 再读取 `<Path>{roots.workflows}/specdev/common/rules/activation-and-memory.md</Path>`,按当前分支、状态和关键词定位最小相关工件。
20
20
  3. 只在本 Work 明确要求恢复、冲突、执行安全或归档证据时扩展为全量读取;缺少匹配证据或 owner/gateway 时停止受影响分支。
21
21
 
22
-
23
- 普通本地请求直接进入适用 Work,不为了完成路由额外创建来源工件。用户明确要求来源审计时仍执行完整 intake;缺陷根因诊断仍交 D,不删除风险分诊与远程回写能力。
22
+ 普通本地请求直接进入适用 Work,不为了完成路由额外创建来源工件。用户明确要求来源审计时仍执行完整 intake;缺陷根因诊断仍交 D,不删除风险分诊与远程回写能力。完成后要记账才选 publish。
24
23
 
25
24
  ## 模式
26
25
 
27
26
  - **intake**:冻结输入、创建或恢复 change、分类并返回下一 Work。
28
- - **reconcile**:本地 change 已完成后,确认远程完成摘要并关闭支持的 GitHub Issue;不重新分诊或修改开发契约。
27
+ - **reconcile**:本地 change 已完成后,确认远程完成摘要并关闭支持的 GitHub 来源 Issue;不重新分诊或修改开发契约。
28
+ - **publish**:本地 change 已完成后,把计划内 Ticket 投影为带分类标签的 GitHub Issue,用脱敏 Evidence 关闭它们,并留下 `<Path>{roots.state}/specdev/changes/{change}/publish.md</Path>` 账本。标签必须包含 `specdev:published`。
29
29
 
30
30
  ## 共同启动
31
31
 
32
32
  1. 解析 roots,按 `<Path>{roots.workflows}/specdev/README.md</Path>` 读取全局状态和 change 状态。
33
- 2. Intake 可以创建 change;reconcile 必须选择一个已存在的 completed change。
33
+ 2. Intake 可以创建 change;reconcile 与 publish 必须选择一个已存在的 change。publish 的对象必须已 completed。
34
34
  3. 若该 change 的 `current_work` 为 null,设置为 `specdev/triage`;指向其他 Work 时先恢复或完成显式 handoff。
35
- 4. 重读已有 `<Path>{roots.state}/specdev/changes/{change}/source.md</Path>` 与 `<Path>{roots.state}/specdev/changes/{change}/triage.md</Path>`,不覆盖已冻结的来源。
35
+ 4. 重读已有 `<Path>{roots.state}/specdev/changes/{change}/source.md</Path>` 与 `<Path>{roots.state}/specdev/changes/{change}/triage.md</Path>`,不覆盖已冻结的来源。publish 在本地源缺失这些文件时可以按协议补建轻量快照,仍不得虚构 GitHub locator。
36
+
37
+ `external_action` 只描述源 Issue。`publish_action` 只描述票级发布投影。两者互不覆盖。未授权时远程写入为零。
36
38
 
37
39
  ## Intake
38
40
 
39
41
  输入为远程 Issue、URL、项目相对文件、用户粘贴内容或当前对话时,加载 `<Path>{roots.workflows}/specdev/T-triage/intake-protocol.md</Path>`:
40
42
 
41
43
  1. 按协议解析来源、查重、脱敏、冻结并计算内容 hash;使用 `<Path>{roots.workflows}/specdev/T-triage/source-template.md</Path>` 原子写入 `<Path>{roots.state}/specdev/changes/{change}/source.md</Path>`。
42
- 2. 按需读取永久 ADR/CONTEXT、当前 change 工件和相关代码事实;缺失的可选输入静默跳过。
43
- 3. 分类为 bug、feature、refactor、investigation、operations、documentation、review 或 mixed
44
+ 2. 按需读取永久 ADR/CONTEXT、当前 change 工件和相关代码事实;缺失的可选输入静默跳过。相似的已归档拒绝或 ADR 只展示,不自动 wontfix,也不回写源 Issue 标签。
45
+ 3. 分类为 bug、feature、refactor、investigation、operations、documentation、review 或 mixed。对照见 `<Path>{roots.workflows}/specdev/T-triage/references/classification-map.md</Path>`。
44
46
  4. 评估影响、紧急度、事故半径、安全、数据、迁移和人工批准;把未知项分为可发现事实、decision-needed 和低影响实现细节。
45
47
  5. 使用 `<Path>{roots.workflows}/specdev/T-triage/triage-template.md</Path>` 写入 `<Path>{roots.state}/specdev/changes/{change}/triage.md</Path>`。
46
48
  6. 运行阶段校验并返回最小正确路线:
@@ -59,11 +61,32 @@ Triage 是 SpecDev 唯一的远程摄入与关闭边界。开发期间,`<Path>
59
61
  本地 change 完成并需要关闭来源 Issue 时,加载:
60
62
 
61
63
  - `<Path>{roots.workflows}/specdev/common/rules/change-completion.md</Path>`;
62
- - `<Path>{roots.workflows}/specdev/T-triage/reconcile-protocol.md</Path>`。
64
+ - `<Path>{roots.workflows}/specdev/T-triage/reconcile-protocol.md</Path>`;
65
+ - `<Path>{roots.workflows}/specdev/T-triage/references/public-projection.md</Path>`。
63
66
 
64
67
  按协议重验本地完成、生成最小外部摘要、展示准确目标和动作、取得本次明确授权,再调用 `<Path>{roots.skills}/github-npm-ops/SKILL.md</Path>`。成功时把 `external_action` 更新为 `closed`;部分或完全失败为 `close-failed` 并保留可重试检查点;用户明确不关闭时为 `waived`。任何结果都不改写本地完成事实。
65
68
 
66
- Reconcile 成功或 waived 后返回 `<Path>{roots.workflows}/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md</Path>`。不支持关闭的来源使用 `not-applicable`,无需虚构 provider。
69
+ Reconcile 成功或 waived 后返回 `<Path>{roots.workflows}/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md</Path>`。不支持关闭的来源使用 `not-applicable`,无需虚构 provider。若用户同时要求 publish,源 Issue 仍不是票 Issue
70
+
71
+ ## Publish
72
+
73
+ 本地 change 完成、用户指定该 change 并要求把 Ticket 记到 GitHub 时,加载:
74
+
75
+ - `<Path>{roots.workflows}/specdev/common/rules/change-completion.md</Path>`;
76
+ - `<Path>{roots.workflows}/specdev/T-triage/publish-protocol.md</Path>`;
77
+ - `<Path>{roots.workflows}/specdev/T-triage/references/classification-map.md</Path>`;
78
+ - `<Path>{roots.workflows}/specdev/T-triage/references/public-projection.md</Path>`。
79
+
80
+ 按协议走硬门、确认表、dry-run,再调用 github-npm-ops 的 `issue-search` / `issue-create` / `issue-comment-close`。账本写 `<Path>{roots.state}/specdev/changes/{change}/publish.md</Path>`。Issue 正文用 `<Path>{roots.workflows}/specdev/T-triage/issue-body-template.md</Path>`,关闭评论用 `<Path>{roots.workflows}/specdev/T-triage/close-comment-template.md</Path>`。
81
+
82
+ 成功 `published`、用户放弃 `waived`,或从未请求的 `not-requested` 才允许归档。`pending` 与 `publish-failed` 挡住 Archive。下一站由用户指定:reconcile、A,或停止。
83
+
84
+ workspace 计数:
85
+
86
+ ```bash
87
+ node <Path>{roots.workflows}/specdev/T-triage/tools/publish-status.mjs</Path> \
88
+ --state-root <Path>{roots.state}/specdev</Path>
89
+ ```
67
90
 
68
91
  ## 状态与验证
69
92
 
@@ -75,7 +98,7 @@ node <Path>{roots.workflows}/specdev/common/tools/validate-specdev.mjs</Path> \
75
98
  <Path>{roots.state}/specdev/changes/{change}</Path>
76
99
  ```
77
100
 
78
- 验证通过后原子重读 source、triage change 状态。Intake 或 reconcile 成功时将 `specdev/triage` 去重加入 `works_run` 并清空 `current_work`;可恢复失败保留 `current_work` 和具体 blocker。
101
+ 验证通过后原子重读 source、triage、publish(若请求过)和 change 状态。Intake、reconcilepublish 成功时将 `specdev/triage` 去重加入 `works_run` 并清空 `current_work`;可恢复失败保留 `current_work` 和具体 blocker。
79
102
 
80
103
  ## 完成标准
81
104
 
@@ -85,11 +108,19 @@ node <Path>{roots.workflows}/specdev/common/tools/validate-specdev.mjs</Path> \
85
108
  - 开发权威完全位于本地 state;
86
109
  - 未授权时远程写入为零;
87
110
  - Reconcile 可从失败检查点幂等恢复;
111
+ - Publish 可从 `<Path>{roots.state}/specdev/changes/{change}/publish.md</Path>` 失败行幂等恢复,cancelled 默认 skip,本地源计入发布数;
88
112
  - 状态、验证结果和下一 Work 完整路径已返回。
89
113
 
90
114
  ## 子文件引用
91
115
 
92
116
  - Intake:`<Path>{roots.workflows}/specdev/T-triage/intake-protocol.md</Path>`
93
117
  - Reconcile:`<Path>{roots.workflows}/specdev/T-triage/reconcile-protocol.md</Path>`
118
+ - Publish:`<Path>{roots.workflows}/specdev/T-triage/publish-protocol.md</Path>`
94
119
  - Source 模板:`<Path>{roots.workflows}/specdev/T-triage/source-template.md</Path>`
95
120
  - Triage 模板:`<Path>{roots.workflows}/specdev/T-triage/triage-template.md</Path>`
121
+ - Publish 模板:`<Path>{roots.workflows}/specdev/T-triage/publish-template.md</Path>`
122
+ - 分类映射:`<Path>{roots.workflows}/specdev/T-triage/references/classification-map.md</Path>`
123
+ - 公共投影:`<Path>{roots.workflows}/specdev/T-triage/references/public-projection.md</Path>`
124
+ - Issue 正文:`<Path>{roots.workflows}/specdev/T-triage/issue-body-template.md</Path>`
125
+ - 关闭评论:`<Path>{roots.workflows}/specdev/T-triage/close-comment-template.md</Path>`
126
+ - 发布计数:`<Path>{roots.workflows}/specdev/T-triage/tools/publish-status.mjs</Path>`
@@ -0,0 +1,42 @@
1
+ # Evidence 关闭评论模板
2
+
3
+ publish 调用 `issue-comment-close` 时使用。先套 `<Path>{roots.workflows}/specdev/T-triage/references/public-projection.md</Path>`。
4
+
5
+ done 票 `--reason completed`:
6
+
7
+ ```markdown
8
+ > *This was generated by AI during SpecDev T-triage publish.*
9
+
10
+ ## Completion
11
+
12
+ **Result:** delivered and verified.
13
+
14
+ **Public commit / PR:** <url or sha or 无公开 commit/PR>
15
+
16
+ **Verification:**
17
+ - Directed: passed | not-run (<reason>)
18
+ - Regression: passed | not-run (<reason>)
19
+ - E2E: passed | not-run (<reason>)
20
+
21
+ **Residual risk:** <可公开残余风险或「无」。>
22
+
23
+ Marker: `specdev:<change>:T-01:published`
24
+ ```
25
+
26
+ 纳入的 cancelled 票 `--reason not_planned`:
27
+
28
+ ```markdown
29
+ > *This was generated by AI during SpecDev T-triage publish.*
30
+
31
+ ## Cancelled
32
+
33
+ **Reason:** cancelled-already-implemented | cancelled-out-of-scope
34
+
35
+ **Notes:** <指到已有公开位置或 ADR / 用户决定;不贴内部路径。>
36
+
37
+ **Public commit / PR:** <url or sha or 无公开 commit/PR>
38
+
39
+ Marker: `specdev:<change>:T-01:published`
40
+ ```
41
+
42
+ 父索引关闭评论 marker 为 `specdev:<change>:publish-index`,正文列出已关闭的子 Issue 编号与计数。
@@ -1,6 +1,6 @@
1
1
  # Intake Protocol
2
2
 
3
- 仅在 Triage `mode=intake` 时加载。本协议把外部输入转换为不可静默覆盖的本地来源快照。
3
+ 仅在 Triage `mode=intake` 时加载。本协议把外部输入转换为不可静默覆盖的本地来源快照。v1 不回写源 Issue 标签。
4
4
 
5
5
  ## 1. 解析来源
6
6
 
@@ -16,6 +16,8 @@
16
16
  - locator 相同但 hash 不同:展示漂移,不覆盖原快照;只有用户确认后才创建新 change,并在新 source 中记录 `supersedes_source`;
17
17
  - 没有稳定 locator:按用户指定 change 或新 change 继续。
18
18
 
19
+ 同时只读扫描永久 ADR、archive 中已关闭的同类请求和当前 active change 标题。发现概念相似项时向用户展示路径与一句话理由,询问是否仍要继续。不自动 wontfix,不新建 `.out-of-scope/` 目录。
20
+
19
21
  ## 3. 冻结内容
20
22
 
21
23
  GitHub 来源保存 title、body、author、state、labels、created/updated 和截至捕获时可见的全部评论。普通 URL 保存可读正文和元数据;项目文件保存内容及项目相对 Path;粘贴或对话保存用户原文。
@@ -29,4 +31,6 @@ GitHub 来源保存 title、body、author、state、labels、created/updated 和
29
31
  - 来源类型、locator、捕获时间、hash 和关闭能力已确定;
30
32
  - 完整可持久化正文与评论已冻结;
31
33
  - 重复和漂移分支有唯一结果;
32
- - 失败没有生成伪造或不完整的 Ready source。
34
+ - 相似归档/ADR 项已展示或明确无匹配;
35
+ - 失败没有生成伪造或不完整的 Ready source;
36
+ - 源 Issue 标签未被本协议修改。
@@ -0,0 +1,34 @@
1
+ # Published Ticket 正文模板
2
+
3
+ publish 创建 GitHub Issue 时使用。先套 `<Path>{roots.workflows}/specdev/T-triage/references/public-projection.md</Path>` 脱敏,再填本模板。占位符不得把 state Path 带出去。
4
+
5
+ ```markdown
6
+ > *This was generated by AI during SpecDev T-triage publish.*
7
+
8
+ ## Published Ticket
9
+
10
+ **Category:** <bug | feature | refactor | investigation | operations | documentation | review>
11
+ **Change:** <change-name>
12
+ **Ticket:** T-01
13
+ **Origin:** local | intake
14
+ **Parent:** Part of #<n> / none
15
+
16
+ **Summary:** <一句话,已经发生的事实。>
17
+
18
+ **Delivered behavior:**
19
+ <完成后系统对外可见的行为。写「现在会怎样」,不写「去改哪个文件」。>
20
+
21
+ **Acceptance:**
22
+ - [x] AC-001:<可公开验收,状态按 Evidence 勾选>
23
+
24
+ **Blocked by:**
25
+ - #<n> <title>
26
+ - None
27
+
28
+ **Out of scope:**
29
+ <票里 OUT 列的可公开项;没有则写「无」。>
30
+
31
+ **Provenance:**
32
+ - Public commit / PR: <url or sha or 无>
33
+ - Marker: `specdev:<change>:T-01:published`
34
+ ```
@@ -0,0 +1,92 @@
1
+ # Publish Protocol
2
+
3
+ 仅在 Triage `mode=publish` 时加载。目标是把已完成 Change 的计划内 Ticket 投影为带分类标签的 GitHub Issue,用脱敏 Evidence 关闭它们,并留下 `<Path>{roots.state}/specdev/changes/{change}/publish.md</Path>`。
4
+
5
+ GitHub 是投影和计数器,不是开发权威。源 Issue 的关闭仍走 reconcile,与本协议互不覆盖。
6
+
7
+ ## 1. 触发与对象
8
+
9
+ 用户必须指定一个已存在的 change(active 或尚未归档的 completed)。禁止扫整个 workspace 自动发。
10
+
11
+ 可选开关:`include-cancelled`、`force-parent` / `no-parent`、排除票号列表。目标 repo 默认从 `git remote` 推断,必须展示确认。
12
+
13
+ 没有先前 intake 时允许补写:
14
+
15
+ - `<Path>{roots.state}/specdev/changes/{change}/triage.md</Path>`,`mode: publish`,`publish_action: pending`;
16
+ - 轻量 `<Path>{roots.state}/specdev/changes/{change}/source.md</Path>`:`source_type` 为 `conversation | pasted | local-file`,`canonical_locator: null`,`close_capability: not-applicable`。禁止虚构 GitHub locator。
17
+
18
+ publish **不得**改 Ticket / Spec / Evidence / Goal / change completed 事实。只写 `<Path>{roots.state}/specdev/changes/{change}/publish.md</Path>`、`<Path>{roots.state}/specdev/changes/{change}/triage.md</Path>` 的 publish 字段,以及经授权的远程投影。
19
+
20
+ ## 2. 硬门
21
+
22
+ 失败即停,零远程写入。`publish_action` 保持 `not-requested`(尚未开始)或变为 `publish-failed`(已开始后失败)。
23
+
24
+ 1. 按 `<Path>{roots.workflows}/specdev/common/rules/change-completion.md</Path>` 重验本地完成。
25
+ 2. 至少一张可发布票:status `done`,或用户选择纳入的 `cancelled`。没有 Ticket 文件(纯 Direct Spec)时停止;v1 不发 Change 级单 Issue。
26
+ 3. 调用 `<Path>{roots.skills}/github-npm-ops/SKILL.md</Path>` 能读目标 repo、能 dry-run `issue-create`。
27
+ 4. 分类映射见 `<Path>{roots.workflows}/specdev/T-triage/references/classification-map.md</Path>`。缺 type 标签时 dry-run 表列出将创建的标签,授权后才 `gh label create`。
28
+ 5. Change `classification` 为 `mixed` 时,确认表上每张纳入票都有 `kind`。
29
+ 6. 用户看见完整 dry-run 表(标题、标签、正文、关闭评论、reason)并给出 **本次** 明确确认。
30
+
31
+ cancelled 默认跳过,账本记 `skipped:cancelled`。只有 `include-cancelled` 或确认表勾选才建 Issue,并以 `not_planned` 关闭。
32
+
33
+ 父 Change Issue:纳入票数 ≥ 2 时默认创建;1 票时票 Issue 本身就是记录。`force-parent` / `no-parent` 覆盖默认。v1 只用正文 `Part of #<n>` / `Blocked by #<n>`,不上 native sub-issue API。
34
+
35
+ ## 3. 准备投影
36
+
37
+ 对每张纳入票读取 Ticket 与对应 Evidence,按 `<Path>{roots.workflows}/specdev/T-triage/references/public-projection.md</Path>` 脱敏。
38
+
39
+ - Issue 正文:`<Path>{roots.workflows}/specdev/T-triage/issue-body-template.md</Path>`(过去时)。
40
+ - 关闭评论:`<Path>{roots.workflows}/specdev/T-triage/close-comment-template.md</Path>`。
41
+ - `kind` 缺省继承 Change 分类;写回票 frontmatter 可选,账本该行必须有 `kind`。
42
+ - origin:源 `close_capability` 为 supported 或 locator 是 GitHub Issue/PR 则为 `intake`,否则 `local`。
43
+ - marker:`specdev:<change>:<ticket-id>:published`。
44
+
45
+ 用 `<Path>{roots.workflows}/specdev/T-triage/publish-template.md</Path>` 写或更新 `<Path>{roots.state}/specdev/changes/{change}/publish.md</Path>`。先把纳入行标 `planned`,跳过行标 `skipped:cancelled` 或 `skipped:excluded`,`publish_action: pending`。
46
+
47
+ ## 4. 幂等执行
48
+
49
+ 顺序:先父(若有)→ 按 `blocked_by` 拓扑建子 → 回写父正文真实编号 → 关子 → 关父。
50
+
51
+ 对每张票:
52
+
53
+ 1. `issue-search` 查 marker 或精确标题 + `specdev:published`。
54
+ 2. 已存在且 marker 在且已关闭 → 账本 `closed`,跳过。
55
+ 3. 已存在且 marker 在但仍 open → 只补 `issue-comment-close`。
56
+ 4. 已存在但无 marker → 停止该行,展示冲突,不覆盖别人的 Issue。
57
+ 5. 不存在 → dry-run `issue-create` → 授权后 `--apply` → 立刻 `issue-comment-close`,marker 双写(正文 Provenance + 关闭评论)。
58
+ 6. 任一步失败:该行 `failed`,记录已完成步骤和下一重试动作;已成功的票不回滚。
59
+
60
+ `--apply` 只表示调用方完成本次确认,不替代授权。远程失败不回滚本地完成事实。
61
+
62
+ 全部纳入票到达 `closed` 或合法 `skipped:*`,且无 `failed` 时:`publish_action: published`,填写 `published_at`。用户明确放弃:`waived`。重跑是恢复,不是重发。
63
+
64
+ 摄入源 Change:本协议只建票 Issue。原来的源 Issue 仍由 reconcile 关闭;源 Issue ≠ 票 Issue。reconcile 评论可以链接已发布编号。
65
+
66
+ ## 5. 校验与计数
67
+
68
+ ```bash
69
+ node <Path>{roots.workflows}/specdev/common/tools/validate-specdev.mjs</Path> \
70
+ --stage triage \
71
+ <Path>{roots.state}/specdev/changes/{change}</Path>
72
+ ```
73
+
74
+ workspace 计数:
75
+
76
+ ```bash
77
+ node <Path>{roots.workflows}/specdev/T-triage/tools/publish-status.mjs</Path> \
78
+ --state-root <Path>{roots.state}/specdev</Path>
79
+ ```
80
+
81
+ 数字只来自 active + archive 的 `<Path>{roots.state}/specdev/changes/{change}/publish.md</Path>`,不把 GitHub 搜索当权威。
82
+
83
+ ## 完成标准
84
+
85
+ - 指定 completed Change 的 done 票均有对应 GitHub Issue,或账本有合法 skip;
86
+ - 每条已发布 Issue 带且仅带一个 type 标签,以及 `specdev:published` 与 `origin:*`;
87
+ - 关闭评论是 Evidence 脱敏投影;
88
+ - marker 使重跑不重复建贴;
89
+ - 本地源计入 `published_issues`;
90
+ - `<Path>{roots.state}/specdev/changes/{change}/publish.md</Path>` 与远程重读一致,失败行可恢复;
91
+ - Ticket / Spec / Evidence / completed 事实未被改写;
92
+ - 未授权时远程写入为零。