diff --git a/README.md b/README.md index 74c2465..8c45403 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,12 @@ shadow-dev issue execute --name <名称> --confirm | `bind status` | 绑定状态 | 各宿主在场情况与托管技能清单 | | `bind unbind` | 解绑 | `--host <名> --confirm`;按 sidecar 精确移除 | +产物能力契约(与 workflow 仓 `pack.mjs` 清单同源登记):产物 `package.json` 可声明 `requiresCommands: ["worktree.inspect", …]`,列出内容层依赖的命令键。CLI 在 **任何落盘动作之前** 拿自身命令目录比对: + +- 缺失即 `ARTIFACT_INCOMPATIBLE`(退出码 1),`CURRENT`/`PREVIOUS`/`LINK` 一律不动,不留半成品版本目录;`workflow link` 直通轨同一断言。 +- 未声明 = 兼容(旧产物零破坏);`workflow plan` 对可离线解析的来源(`--from` 目录/tarball)透出 `missingCommands` 预览,下载轨为 `null`。 +- `workflow status` 回 `artifactVersion`/`cliVersion`/`missingCommands` 三元组,供引导页与巡检读一个信号即可判断「内容是否比 CLI 新」。 + 安装布局: ``` @@ -203,7 +209,7 @@ shadow-dev issue execute --name <名称> --confirm | 码 | 含义 | 典型错误码 | |----|------|-----------| | 0 | 成功 | — | -| 1 | 输入/校验错误 | `PLAN_HASH_INVALID`、`BRIEF_NOT_FOUND`、`NAME_REQUIRED`、`TASKS_NOT_COMPLETE`、`REVIEW_NOT_PASSED`、`PR_NOT_MERGED`、`ARTIFACT_INVALID`、`UNMANAGED_TARGET`、`BLOG_FILE_NOT_FOUND`、`BLOG_TITLE_REQUIRED` | +| 1 | 输入/校验错误 | `PLAN_HASH_INVALID`、`BRIEF_NOT_FOUND`、`NAME_REQUIRED`、`TASKS_NOT_COMPLETE`、`REVIEW_NOT_PASSED`、`PR_NOT_MERGED`、`ARTIFACT_INVALID`、`ARTIFACT_INCOMPATIBLE`、`UNMANAGED_TARGET`、`BLOG_FILE_NOT_FOUND`、`BLOG_TITLE_REQUIRED` | | 2 | 缺少确认或凭证 | `CONFIRMATION_REQUIRED`、`PLAN_HASH_REQUIRED`、`BLOG_FILE_REQUIRED` | | 3 | 外部系统失败 | `GITHUB_TOKEN_REQUIRED`、`GITHUB_API_ERROR`、`API_TIMEOUT`、`GIT_PUSH_FAILED`、`DOWNLOAD_FAILED`、`RELEASE_NOT_FOUND` | | 4 | 不支持的操作 | `UNSUPPORTED_OPERATION`(如 `git add .`、绝对路径) | @@ -282,6 +288,7 @@ bash scripts/install-cli.sh unlink # 移除 CLI 的 LINK | `GITHUB_TOKEN_REQUIRED` | `export GH_TOKEN="$(gh auth token)"` | | `WORKFLOW_NOT_INSTALLED` | 先跑 workflow plan + execute,或直接跑 bootstrap | | `ADAPTERS_MISSING` | workflow 产物过旧(< v6.3.1),更新产物 | +| `ARTIFACT_INCOMPATIBLE` | 产物声明的 `requiresCommands` 当前 CLI 不提供(内容比 CLI 新);先升级 CLI 再重新 `workflow plan`——拒绝发生在落盘前,`CURRENT`/`PREVIOUS`/`LINK` 与已装版本都不会被改动 | | `UNMANAGED_TARGET` | 宿主目录存在同名非托管技能;手动移除后重试(绝不静默覆盖) | | `DIRTY_WORKTREE` | 工作区有未提交业务改动,先 commit 或还原 | | 插件 hook 提示「未能就位」 | 离线导致;联网后重开会话,或手动 `bash scripts/install-cli.sh install` | diff --git a/lib/commands.mjs b/lib/commands.mjs index eed69fa..f855a73 100644 --- a/lib/commands.mjs +++ b/lib/commands.mjs @@ -47,11 +47,11 @@ export const COMMANDS = { 'blog.publish.execute': c('blog publish execute', '创建 Issue 并即时触发主站同步(无 brief 域,--plan-hash 为唯一凭证;同步失败不阻断)', 'create the issue and ping the sync endpoint (briefless: --plan-hash is the only credential; sync failure is best-effort)', [f('--file', true, '与 plan 相同的文章路径(参与 planHash 重算)', 'same article path as the plan (re-hashed for the planHash check)'), f('--repository', false, '与 plan 一致的目标仓库', 'same target repository as the plan'), f('--sync-url', false, '与 plan 一致的同步基址', 'same sync base url as the plan'), f('--plan-hash', true, 'blog publish plan 的输出', 'hash from blog publish plan'), CF], 'shadow-dev blog publish execute --file --plan-hash --confirm', null), 'index.rebuild.plan': c('index rebuild plan', '预览变更索引重建', 'plan rebuilding the change index', [], 'shadow-dev index rebuild plan', 'index rebuild execute --plan-hash {planHash} --confirm'), 'index.rebuild.execute': c('index rebuild execute', '重建 INDEX.md(无 brief 域,--plan-hash 为唯一凭证)', 'rebuild INDEX.md (briefless command: --plan-hash is the only credential)', [f('--plan-hash', true, 'index rebuild plan 的输出', 'hash from index rebuild plan'), CF], 'shadow-dev index rebuild execute --plan-hash --confirm', null), - 'workflow.plan': c('workflow plan', '预览 workflow 产物安装(--release/--from/latest 三路解析)', 'plan the workflow artifact install (--release/--from/latest)', [f('--release', false, '固定 release tag,缺省取 latest', 'pin a release tag; defaults to latest'), f('--from', false, '本地目录或 tarball(离线安装)', 'local dir or tarball (offline install)'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow plan', 'workflow execute --plan-hash {planHash} --confirm'), - 'workflow.execute': c('workflow execute', '物化产物并切指针(先冒烟后落盘,留 PREVIOUS 供回滚)', 'materialize the artifact and flip pointers (smoke first, PREVIOUS kept for rollback)', [f('--plan-hash', true, 'workflow plan 的输出', 'hash from workflow plan'), f('--release', false, '固定 release tag', 'pin a release tag'), f('--from', false, '本地目录或 tarball', 'local dir or tarball'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow execute --plan-hash --confirm', 'bind plan --host auto'), + 'workflow.plan': c('workflow plan', '预览 workflow 产物安装(--release/--from/latest 三路解析),离线可解析来源时透出 missingCommands 预览', 'plan the workflow artifact install (--release/--from/latest), previewing missingCommands whenever the source is resolvable offline', [f('--release', false, '固定 release tag,缺省取 latest', 'pin a release tag; defaults to latest'), f('--from', false, '本地目录或 tarball(离线安装)', 'local dir or tarball (offline install)'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow plan', 'workflow execute --plan-hash {planHash} --confirm'), + 'workflow.execute': c('workflow execute', '物化产物:落盘前断言 requiresCommands,能力不齐 ARTIFACT_INCOMPATIBLE 且指针不动', 'materialize the artifact, asserting requiresCommands before any write; ARTIFACT_INCOMPATIBLE keeps the pointers untouched', [f('--plan-hash', true, 'workflow plan 的输出', 'hash from workflow plan'), f('--release', false, '固定 release tag', 'pin a release tag'), f('--from', false, '本地目录或 tarball', 'local dir or tarball'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow execute --plan-hash --confirm', 'bind plan --host auto'), 'workflow.rollback': c('workflow rollback', '回滚到上一版(离线,PREVIOUS/CURRENT 对调)', 'roll back to the previous version (offline pointer swap)', [CF, f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow rollback --confirm', null), - 'workflow.status': c('workflow status', '查看 current/previous/linked 与解析出的产物根', 'show current/previous/linked and the resolved artifact root', [f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow status', null), - 'workflow.link': c('workflow link', 'link 直通轨:指向本机 checkout,改动即生效', 'link track: point at a local checkout, edits take effect immediately', [f('--dir', true, '产物目录(含 marketplace.json/package.json/skills)', 'artifact dir with marketplace.json/package.json/skills'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow link --dir --confirm', 'bind plan --host auto'), + 'workflow.status': c('workflow status', '查看 current/previous/linked、产物根与版本三元组 artifactVersion/cliVersion/missingCommands', 'show current/previous/linked, the artifact root and the version triplet artifactVersion/cliVersion/missingCommands', [f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow status', null), + 'workflow.link': c('workflow link', 'link 直通轨:指向本机 checkout,改动即生效;同样先过能力断言', 'link track: point at a local checkout, edits take effect immediately; the capability assertion applies here too', [f('--dir', true, '产物目录(含 marketplace.json/package.json/skills)', 'artifact dir with marketplace.json/package.json/skills'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow link --dir --confirm', 'bind plan --host auto'), 'workflow.unlink': c('workflow unlink', '移除 LINK,回落 CURRENT 版本', 'remove the LINK pointer and fall back to CURRENT', [f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow unlink --confirm', null), 'bind.plan': c('bind plan', '预览按 adapters/.json 把 skills 绑入宿主目录(--host auto 探测)', 'plan binding skills into host dirs via adapters/.json (--host auto detects)', [f('--host', false, 'auto|claude-code|zcode…,缺省 auto', 'auto|claude-code|zcode…; defaults to auto'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev bind plan --host auto', 'bind execute --plan-hash {planHash} --confirm'), 'bind.execute': c('bind execute', '执行绑定(复制 + sidecar 托管标记,非托管同名目录拒绝覆盖)', 'run the binding (copy + sidecar marker; unmanaged targets are refused)', [f('--plan-hash', true, 'bind plan 的输出', 'hash from bind plan'), f('--host', false, 'auto|claude-code|zcode…', 'auto|claude-code|zcode…'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev bind execute --plan-hash --confirm', 'bind status'), diff --git a/lib/domains/workflow.mjs b/lib/domains/workflow.mjs index ed24280..5fac0a7 100644 --- a/lib/domains/workflow.mjs +++ b/lib/domains/workflow.mjs @@ -5,6 +5,8 @@ import { execFileSync, spawnSync } from 'node:child_process' import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { homedir, platform, tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { COMMANDS } from '../commands.mjs' +import { VERSION } from '../version.mjs' import { err } from '../errors.mjs' import { plan } from '../plan.mjs' import { confirm } from '../input.mjs' @@ -57,6 +59,24 @@ async function download(url, out) { } const verOf = d => { try { return JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')).version } catch { throw err('ARTIFACT_INVALID', 'ARTIFACT_INVALID: package.json unreadable', 3) } } +const safeVer = d => { try { return JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')).version ?? null } catch { return null } } + +// 能力契约(产物 package.json 的可选第四件):requiresCommands 声明内容层需要的命令键。 +// 判定发生在任何落盘之前——与「冒烟不过则 CURRENT 不动」同一安全语义(install-distribution 卡)。 +// 排序保证 planHash 与投影字节确定;缺省/非数组 = 旧产物,视为兼容(零破坏)。 +export const requiresOf = d => { + try { + const r = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')).requiresCommands + return Array.isArray(r) ? [...r].sort() : [] + } catch { return [] } +} +export const missingFor = req => req.filter(k => !Object.hasOwn(COMMANDS, k)) +export const missingCommandsOf = d => missingFor(requiresOf(d)) +// 断言只在能离线解析来源时给 plan 透出预览;release/latest 轨下载前无从得知,恒为 null +function assertCapabilities(d, ver) { + const missing = missingCommandsOf(d) + if (missing.length) throw err('ARTIFACT_INCOMPATIBLE', `ARTIFACT_INCOMPATIBLE: artifact ${ver ?? '(unknown)'} requires commands this CLI ${VERSION} does not provide: ${missing.join(', ')} — upgrade shadow-dev, then plan again`, 1) +} // 产物契约(与 workflow 仓 pack.mjs 布局同源):marketplace.json/package.json/skills 三件必备 export function requireArtifact(d) { @@ -66,22 +86,23 @@ export function requireArtifact(d) { // 计划:三路同源解析目标版本——--from(目录/tarball)、--release(固定 tag)、latest export async function planData(_r, o) { - const base = { action: 'install', prefix: prefix(o), current: current(o), previous: previous(o), linked: linked(o) } + const base = { action: 'install', prefix: prefix(o), current: current(o), previous: previous(o), linked: linked(o), missingCommands: null } if (o.from) { const src = resolve(o.from) - if (statSync(src).isDirectory()) { requireArtifact(src); return { ...base, version: verOf(src), source: { dir: src } } } + if (statSync(src).isDirectory()) { requireArtifact(src); return { ...base, version: verOf(src), source: { dir: src }, missingCommands: missingCommandsOf(src) } } const tmp = mkdtempSync(join(tmpdir(), 'wf-plan-')) try { untar(src, tmp) const w = join(tmp, 'shadow-dev-workflow') requireArtifact(w) - return { ...base, version: verOf(w), source: { tarball: resolve(src) } } + return { ...base, version: verOf(w), source: { tarball: resolve(src) }, missingCommands: missingCommandsOf(w) } } finally { rmSync(tmp, { recursive: true, force: true }) } } const rel = await ghJson(o.release ? `/repos/${REPO}/releases/tags/${o.release}` : `/repos/${REPO}/releases/latest`) const asset = (rel.assets || []).find(a => /^shadow-dev-workflow-v[0-9][0-9.]*\.tar\.gz$/.test(a.name)) if (!asset) throw err('RELEASE_NOT_FOUND', 'RELEASE_NOT_FOUND: release carries no shadow-dev-workflow tarball asset', 3) - return { ...base, version: String(rel.tag_name).replace(/^v/, ''), source: { url: asset.browser_download_url, tag: rel.tag_name } } + // 来源需下载后才可读:能力预览留 null,断言仍由 execute 在落盘前执行 + return { ...base, version: String(rel.tag_name).replace(/^v/, ''), source: { url: asset.browser_download_url, tag: rel.tag_name }, missingCommands: null } } function prune(p, ver) { @@ -111,6 +132,7 @@ export async function executeDomain(o) { } requireArtifact(src) const p = x.prefix, ver = verOf(src) + assertCapabilities(src, ver) const dest = join(p, `shadow-dev-workflow-${ver}`) rmSync(dest, { recursive: true, force: true }) mkdirSync(p, { recursive: true }) @@ -139,6 +161,7 @@ export function linkDomain(o) { if (!o.dir) throw err('DIR_REQUIRED', 'DIR_REQUIRED: pass --dir ', 1) const target = resolve(o.dir) requireArtifact(target) + assertCapabilities(target, verOf(target)) const p = prefix(o) mkdirSync(p, { recursive: true }) // LINK 只删不写于 install 轨;语义互斥与 CLI 自身安装器同款(unlink 只删 LINK) @@ -159,7 +182,8 @@ export function statusDomain(o) { const l = readPtr(p, 'LINK') const c = readPtr(p, 'CURRENT') const resolved = l && existsSync(l) ? l : c && existsSync(join(p, `shadow-dev-workflow-${c}`)) ? join(p, `shadow-dev-workflow-${c}`) : null - return { prefix: p, current: c, previous: readPtr(p, 'PREVIOUS'), linked: l, resolved } + // 可观测面:status 恒不抛——产物不可读时版本为 null、能力缺口为空集 + return { prefix: p, current: c, previous: readPtr(p, 'PREVIOUS'), linked: l, resolved, artifactVersion: resolved ? safeVer(resolved) : null, cliVersion: VERSION, missingCommands: resolved ? missingCommandsOf(resolved) : [] } } export async function handle(a, o) { diff --git a/lib/i18n.mjs b/lib/i18n.mjs index 114db8b..7d1abae 100644 --- a/lib/i18n.mjs +++ b/lib/i18n.mjs @@ -71,6 +71,7 @@ export const HINTS = { API_TIMEOUT: { zh: 'GitHub API 超时(SHADOW_API_TIMEOUT_MS 可调)', en: 'GitHub API timed out (tune SHADOW_API_TIMEOUT_MS)' }, WORKFLOW_NOT_INSTALLED: { zh: 'workflow 产物未安装;先运行 workflow plan + workflow execute', en: 'workflow artifact not installed; run workflow plan + workflow execute first' }, ARTIFACT_INVALID: { zh: '产物缺少 marketplace.json/package.json/skills 契约文件', en: 'artifact is missing its marketplace.json/package.json/skills contract' }, + ARTIFACT_INCOMPATIBLE: { zh: '产物声明的 requiresCommands 在当前 CLI 里不存在;升级 shadow-dev 后重新 plan', en: 'the artifact declares requiresCommands this CLI does not provide; upgrade shadow-dev and plan again' }, RELEASE_NOT_FOUND: { zh: '找不到 release 或其 tarball 资产(--release 可固定 tag)', en: 'release or its tarball asset not found (pin one with --release)' }, DOWNLOAD_FAILED: { zh: 'release 资产下载失败(网络或 URL 形态异常)', en: 'release asset download failed (network or malformed url)' }, NO_PREVIOUS: { zh: '没有可回滚的上一版本', en: 'no previous version available for rollback' }, diff --git a/shadow-docs/changes/20261008-feature-artifact-capability-contract/brief.md b/shadow-docs/changes/20261008-feature-artifact-capability-contract/brief.md new file mode 100644 index 0000000..905b4ee --- /dev/null +++ b/shadow-docs/changes/20261008-feature-artifact-capability-contract/brief.md @@ -0,0 +1,157 @@ +--- +{ + "schema": "shadow-dev/v1", + "name": "20261008-feature-artifact-capability-contract", + "type": "feature", + "scope": "lib/domains/workflow.mjs,lib/commands.mjs,test", + "status": "published", + "baseBranch": "main", + "branch": "feature/20261008-feature-artifact-capability-contract", + "files": [ + "README.md", + "lib/commands.mjs", + "lib/domains/workflow.mjs", + "lib/errors.mjs", + "lib/human.mjs", + "lib/i18n.mjs", + "shadow-docs/knowledge/install-distribution.md", + "shadow-docs/menu.md", + "test/cli.test.mjs" + ], + "github": { + "repository": "stack-wuh/shadow-dev-cli", + "issue": 46, + "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/46", + "pullRequest": 47, + "pullRequestUrl": "https://github.com/stack-wuh/shadow-dev-cli/pull/47" + }, + "review": { + "conclusion": "passed", + "verifiedCommit": "5dc4825d83f902a80845816291dd971816cd5728", + "verifiedAt": "2026-10-09T00:31:46.552Z" + }, + "workflow": { + "operation": null, + "checkpoint": "pr:47", + "planHash": "0ccd06d978d95ac31b3497ee583b05a0b029309989cc08b696260478fd08bddc", + "updatedAt": null, + "lastError": null, + "issuePlan": { + "title": "[feature] 产物能力契约:workflow 产物自声明 requiresCommands,CLI 物化前断言", + "titleRaw": null, + "supplement": "", + "body": "## 动机\n分发权威正在从「workflow 插件引导 CLI」反转为「CLI 驱动 workflow 内容」(用户已定:一律走 CLI,后续以引导网页为入口)。反转的前提是删除 workflow 仓的 `cliVersion` pin 与 SessionStart 自举 hook——但 pin 一删,「skills 引用的命令在已安装 CLI 里不存在」这件事就再没有任何机制兜住。实证:pin 为 `v1.4.0`(已安装版),而 propose/apply/archive 三个 skill 已在指挥 `worktree inspect|plan|execute|remove`,worktree 域只在 main 上、v1.5.0 从未发布;skill 里的降级写法「命令不可用时直接走 branch」让缺口静默化。\n\n本变更把版本兼容从「静态 pin」升级为「运行时能力断言」:产物自声明它需要哪些命令,CLI 在物化落盘前拿自身 `COMMANDS` 目录比对,不满足就响亮拒绝且指针不动。这是反转的承重墙,必须先落地。\n\n## 引用规范\n- `shadow-docs/knowledge/install-distribution.md`\n - 当前结论: 双轨模型(release 物化轨 / link 直通轨);**指针落盘前置校验不可绕过**——物化轨冒烟不过则 `CURRENT` 不动;workflow 产物消费契约三件必备(`marketplace.json`/`package.json`/`skills/`)+ `adapters/`(≥v6.3.1);**pack 清单与消费方契约两处同源登记**;**release 不作为 brief task**(机械门禁要求全任务勾选,发布在 review 之后,写成 task 会死锁)。\n - 适用 scope: `lib/domains/workflow.mjs`, `test/cli.test.mjs`\n- `shadow-docs/knowledge/cli-output-contract.md`\n - 当前结论: 新增输出必须二选一(stdout JSON 契约=公开 API 需测试钉住 / stderr 人用层);`lib/commands.mjs` 是两通道单一事实源;`nextStep`/新增字段为 additive 且不得参与 hash 输入;错误 code 永不本地化;**stderr 文案断言必须显式钉 `--lang`**(CI runner locale 会改变渲染语言)。\n - 适用 scope: `lib/domains/workflow.mjs`, `lib/commands.mjs`, `lib/human.mjs`, `README.md`\n- `shadow-docs/knowledge/plan-credential-chain.md`\n - 当前结论: 无 brief 域以 `--plan-hash` 为唯一凭证,plan/execute 用同一 planData 重算对比;易变字段须由 `plan.norm()` 剥离后再入 hash。\n - 适用 scope: `lib/domains/workflow.mjs`(能力断言发生在 execute 的 planData 重算之后,不得改变 hash 输入语义)\n- `norms/tdd-verification.md`\n - 当前结论: L 级完整 TDD——先写能复现失败的测试并确认失败,再最小实现;无失败测试记录视为未开始;进度可见性(开工报数、逐完成一行、卡点先报)。\n - 适用 scope: 全流程\n- `norms/knowledge-cards.md`\n - 当前结论: 能更新现有卡时不新增;更新须补 `verified-depth` + `verified-scope`,`runtime` 声明必须附可追溯观察点。\n - 适用 scope: `shadow-docs/knowledge/`\n\n## 决策\n- **选型:** 方案 A——**CLI-first**。CLI 侧新增产物能力断言并发版(v1.5.0);随后 workflow 仓 change 才声明 `requiresCommands` 并删除 `cliVersion` pin / SessionStart hook / vendored install-cli.sh。\n- **对比方案:**\n - 方案 B(workflow-first,即我上一轮表格原顺序:先删 pin 与 hook):未选。删除后、能力校验上线前存在窗口,期间「内容要求未发布命令」既无 pin 也无断言,与本次要消灭的缺陷同类。\n - 方案 C(双仓同一 change):未选。违反一 PR 一仓与小步快跑,且 CLI 需发版、workflow 不需,发布节奏不同;跨仓接缝本来就该按「消费方先行兼容、生产方随后声明」的顺序演进。\n - 方案 A 变体(CLI 侧继续读 pin / 维护 cliVersion 兼容矩阵):未选。pin 是静态断言,需要每次发版人工同步(本轮已实证同步失败:README 写 v1.1.0、pin v1.4.0、skills 要 v1.5.0);能力名是产物与 CLI 之间唯一会自然保持同步的货币。\n- **理由:** 兼容判定发生在**装不装得下来**这个不可绕过的关口,复用既有「冒烟不过则指针不动」的安全语义,零新机制;缺省(产物未声明 `requiresCommands`)视为兼容,旧产物零破坏;`workflow status` 暴露 `missingCommands` 让引导网页有一个可读的健康信号。\n\n## 任务\n### Phase 1 — 先红(确认失败)\n\n- [x] 契约用例·拒绝路径 — `test/cli.test.mjs` — fixture 产物 package.json 声明 `requiresCommands: [\"worktree.inspect\"]`,跑 `workflow execute --from `:断言 exit 1、错误码 `ARTIFACT_INCOMPATIBLE`、`CURRENT`/`PREVIOUS` 内容不变、目标版本目录不残留(stderr 断言显式钉 `--lang zh`)。**先跑,确认为红。**\n- [x] 契约用例·缺省兼容 — `test/cli.test.mjs` — 无 `requiresCommands` 字段的既有 fixture 仍成功物化并切指针(当前实现应已满足,跑绿作为回归钉)。\n- [x] 契约用例·status 投影 — `test/cli.test.mjs` — `workflow status` 新增 `artifactVersion`/`cliVersion`/`missingCommands`(additive,缺省空数组)且参与 stdout 契约。先红。\n\n### Phase 2 — 最小实现转绿\n\n- [x] 能力断言落点 — `lib/domains/workflow.mjs` — 物化校验段(现 `verify()` / `verOf()` 附近、冒烟之前或同段)读取产物 `package.json.requiresCommands`,与 `lib/commands.mjs` 导出的 `COMMANDS` 键集合比对;缺失即 `err('ARTIFACT_INCOMPATIBLE', …, 1)`,不写指针。link 轨同样断言。\n- [x] status 与 plan 投影 — `lib/domains/workflow.mjs` — `status`/`resolvedRoot` 消费同一判定函数,产出上述三字段;`present()` 保持最小投影(不整段回显产物目录)。\n- [x] 目录与文档同源 — `lib/commands.mjs`, `README.md` — 新错误码进退出码表(1 类:产物/校验),`workflow execute` 的 args.desc 与示例同步;README「核心机制/排障」两表登记 `ARTIFACT_INCOMPATIBLE` 的处置(升级 CLI)。\n\n### Phase 3 — 收口\n\n- [x] 错误面登记 — `lib/errors.mjs`, `lib/human.mjs`, `lib/i18n.mjs` — 新码的 HINTS 短句与 zh/en 文案(stdout 不本地化,stderr 双版)。\n- [ ] 全量回归 — `test/cli.test.mjs` — `npm test` 全绿;**执行环境以 CI `test.yml`(ubuntu/macos/windows × node 20/22/24)为准**:开发机本机 `node --test` 在 git-fixture 前置组即挂死(spec reporter 0 用例回报完成),全绿证据=CI run 链接。同时确认 hash 输入未因新增字段漂移(`plan.norm()` 语义不变)。\n- [x] 跨仓顺序验证 — `test/cli.test.mjs` — 用 `--from` 造一个「声明了当前 CLI 不存在的命令」的产物目录,手跑一次完整 plan→execute,贴出拒绝输出作为 runtime 观察点。\n\n完整 brief:shadow-docs/changes/20261008-feature-artifact-capability-contract/brief.md\n\n\n", + "labels": [ + "feature" + ] + }, + "commit": { + "files": [ + "shadow-docs/changes/20261008-feature-artifact-capability-contract/brief.md" + ], + "message": "docs(shadow): 结果段回填——CI 9/9 + runtime 10/10 证据" + }, + "release": { + "files": [ + "shadow-docs/changes/20261008-feature-artifact-capability-contract/brief.md" + ], + "message": "docs(shadow): review 结论重钉至最终提交 5dc4825(CI run 37864859648 9/9)", + "title": "[feature] 产物能力契约:workflow 产物自声明 requiresCommands,CLI 物化前断言 (#46)", + "body": "Closes #46\n\n完整 brief:shadow-docs/changes/20261008-feature-artifact-capability-contract/brief.md" + } + }, + "knowledge": { + "action": "更新", + "target": "shadow-docs/knowledge/install-distribution.md", + "reason": "卡片已收录 requiresCommands 能力契约、落盘前断言不可绕过与 status 三元组;最终提交 5dc4825 的 CI(run 37864859648)9/9 通过,验证强度达 L 级期望的 runtime" + } +} +--- + +# 产物能力契约:workflow 产物自声明 requiresCommands,CLI 物化前断言 + +## 动机 + +分发权威正在从「workflow 插件引导 CLI」反转为「CLI 驱动 workflow 内容」(用户已定:一律走 CLI,后续以引导网页为入口)。反转的前提是删除 workflow 仓的 `cliVersion` pin 与 SessionStart 自举 hook——但 pin 一删,「skills 引用的命令在已安装 CLI 里不存在」这件事就再没有任何机制兜住。实证:pin 为 `v1.4.0`(已安装版),而 propose/apply/archive 三个 skill 已在指挥 `worktree inspect|plan|execute|remove`,worktree 域只在 main 上、v1.5.0 从未发布;skill 里的降级写法「命令不可用时直接走 branch」让缺口静默化。 + +本变更把版本兼容从「静态 pin」升级为「运行时能力断言」:产物自声明它需要哪些命令,CLI 在物化落盘前拿自身 `COMMANDS` 目录比对,不满足就响亮拒绝且指针不动。这是反转的承重墙,必须先落地。 + +## 复杂度评级 + +- **评级:** L +- **理由:** 三要素——①契约变更:产物消费契约新增必备项(`requiresCommands`)+ 新增错误码 + `workflow status` stdout 契约新增字段,且是跨仓接缝;②触及面:分发域核心(`lib/domains/workflow.mjs`)与命令目录单一事实源(`lib/commands.mjs`);③可发现性:坏在「装不上/旧 CLI 才可见」,运行时路径依赖外部产物与真实文件系统,静态读码看不出来。 +- **期望验证深度:** runtime(隔离 `SHADOW_WORKFLOW_PREFIX` + `--from` fixture 产物做真实物化,观察拒绝路径下 CURRENT/PREVIOUS 字节不变、放行路径正常切指针) + +## 引用规范 + +- `shadow-docs/knowledge/install-distribution.md` + - 当前结论: 双轨模型(release 物化轨 / link 直通轨);**指针落盘前置校验不可绕过**——物化轨冒烟不过则 `CURRENT` 不动;workflow 产物消费契约三件必备(`marketplace.json`/`package.json`/`skills/`)+ `adapters/`(≥v6.3.1);**pack 清单与消费方契约两处同源登记**;**release 不作为 brief task**(机械门禁要求全任务勾选,发布在 review 之后,写成 task 会死锁)。 + - 适用 scope: `lib/domains/workflow.mjs`, `test/cli.test.mjs` +- `shadow-docs/knowledge/cli-output-contract.md` + - 当前结论: 新增输出必须二选一(stdout JSON 契约=公开 API 需测试钉住 / stderr 人用层);`lib/commands.mjs` 是两通道单一事实源;`nextStep`/新增字段为 additive 且不得参与 hash 输入;错误 code 永不本地化;**stderr 文案断言必须显式钉 `--lang`**(CI runner locale 会改变渲染语言)。 + - 适用 scope: `lib/domains/workflow.mjs`, `lib/commands.mjs`, `lib/human.mjs`, `README.md` +- `shadow-docs/knowledge/plan-credential-chain.md` + - 当前结论: 无 brief 域以 `--plan-hash` 为唯一凭证,plan/execute 用同一 planData 重算对比;易变字段须由 `plan.norm()` 剥离后再入 hash。 + - 适用 scope: `lib/domains/workflow.mjs`(能力断言发生在 execute 的 planData 重算之后,不得改变 hash 输入语义) +- `norms/tdd-verification.md` + - 当前结论: L 级完整 TDD——先写能复现失败的测试并确认失败,再最小实现;无失败测试记录视为未开始;进度可见性(开工报数、逐完成一行、卡点先报)。 + - 适用 scope: 全流程 +- `norms/knowledge-cards.md` + - 当前结论: 能更新现有卡时不新增;更新须补 `verified-depth` + `verified-scope`,`runtime` 声明必须附可追溯观察点。 + - 适用 scope: `shadow-docs/knowledge/` + +## 决策 + +- **选型:** 方案 A——**CLI-first**。CLI 侧新增产物能力断言并发版(v1.5.0);随后 workflow 仓 change 才声明 `requiresCommands` 并删除 `cliVersion` pin / SessionStart hook / vendored install-cli.sh。 +- **对比方案:** + - 方案 B(workflow-first,即我上一轮表格原顺序:先删 pin 与 hook):未选。删除后、能力校验上线前存在窗口,期间「内容要求未发布命令」既无 pin 也无断言,与本次要消灭的缺陷同类。 + - 方案 C(双仓同一 change):未选。违反一 PR 一仓与小步快跑,且 CLI 需发版、workflow 不需,发布节奏不同;跨仓接缝本来就该按「消费方先行兼容、生产方随后声明」的顺序演进。 + - 方案 A 变体(CLI 侧继续读 pin / 维护 cliVersion 兼容矩阵):未选。pin 是静态断言,需要每次发版人工同步(本轮已实证同步失败:README 写 v1.1.0、pin v1.4.0、skills 要 v1.5.0);能力名是产物与 CLI 之间唯一会自然保持同步的货币。 +- **理由:** 兼容判定发生在**装不装得下来**这个不可绕过的关口,复用既有「冒烟不过则指针不动」的安全语义,零新机制;缺省(产物未声明 `requiresCommands`)视为兼容,旧产物零破坏;`workflow status` 暴露 `missingCommands` 让引导网页有一个可读的健康信号。 + +## 任务 + +### Phase 1 — 先红(确认失败) + +- [x] 契约用例·拒绝路径 — `test/cli.test.mjs` — fixture 产物 package.json 声明 `requiresCommands: ["worktree.inspect"]`,跑 `workflow execute --from `:断言 exit 1、错误码 `ARTIFACT_INCOMPATIBLE`、`CURRENT`/`PREVIOUS` 内容不变、目标版本目录不残留(stderr 断言显式钉 `--lang zh`)。**先跑,确认为红。** +- [x] 契约用例·缺省兼容 — `test/cli.test.mjs` — 无 `requiresCommands` 字段的既有 fixture 仍成功物化并切指针(当前实现应已满足,跑绿作为回归钉)。 +- [x] 契约用例·status 投影 — `test/cli.test.mjs` — `workflow status` 新增 `artifactVersion`/`cliVersion`/`missingCommands`(additive,缺省空数组)且参与 stdout 契约。先红。 + +### Phase 2 — 最小实现转绿 + +- [x] 能力断言落点 — `lib/domains/workflow.mjs` — 物化校验段(现 `verify()` / `verOf()` 附近、冒烟之前或同段)读取产物 `package.json.requiresCommands`,与 `lib/commands.mjs` 导出的 `COMMANDS` 键集合比对;缺失即 `err('ARTIFACT_INCOMPATIBLE', …, 1)`,不写指针。link 轨同样断言。 +- [x] status 与 plan 投影 — `lib/domains/workflow.mjs` — `status`/`resolvedRoot` 消费同一判定函数,产出上述三字段;`present()` 保持最小投影(不整段回显产物目录)。 +- [x] 目录与文档同源 — `lib/commands.mjs`, `README.md` — 新错误码进退出码表(1 类:产物/校验),`workflow execute` 的 args.desc 与示例同步;README「核心机制/排障」两表登记 `ARTIFACT_INCOMPATIBLE` 的处置(升级 CLI)。 + +### Phase 3 — 收口 + +- [x] 错误面登记 — `lib/errors.mjs`, `lib/human.mjs`, `lib/i18n.mjs` — 新码的 HINTS 短句与 zh/en 文案(stdout 不本地化,stderr 双版)。 +- [x] 全量回归 — `test/cli.test.mjs` — `npm test` 全绿;**执行环境以 CI `test.yml`(ubuntu/macos/windows × node 20/22/24)为准**:开发机本机 `node --test` 在 git-fixture 前置组即挂死(spec reporter 0 用例回报完成),全绿证据=CI run 链接。同时确认 hash 输入未因新增字段漂移(`plan.norm()` 语义不变)。 +- [x] 跨仓顺序验证 — `test/cli.test.mjs` — 用 `--from` 造一个「声明了当前 CLI 不存在的命令」的产物目录,手跑一次完整 plan→execute,贴出拒绝输出作为 runtime 观察点。 + +## 非目标 + +- 不在本 change 动 workflow 仓(删 pin/hook/vendored installer、补 `adapters/codex.json`、README 命令面纠偏)——紧随其后的独立 change。 +- 不做 `template/` 目录与 `project init|doctor|upgrade`——第三个 change。 +- 不改 `bind` 语义与 sidecar 格式;不引入版本号兼容矩阵。 +- 发版(tag + GitHub Release)不作为 task:按 install-distribution 卡的既有教训,发布在 review 之后的交付环节执行。 + +## 结果 + +- 实际耗时: ≈75 分钟墙钟(其中 ~12 分钟耗在本机 `node --test` 挂死定位与重试;含 CI 等待 ~6 分钟) +- 验证: + - **全量回归(task-8,期望深度 runtime)= CI**:`gh pr checks 47` → **9/9 pass**(ubuntu/macos/windows × node 20/22/24,`node --test test/cli.test.mjs test/install.test.mjs` 共 105 用例)。两次 run 均 `completed success`:run 37864130964(push 触发)、run 37864139301(PR 触发),https://github.com/stack-wuh/shadow-dev-cli/actions/runs/37864139301 + - **契约用例 4 条**:实现前逐条红(`not ok`/断言不符),实现后 `--test-name-pattern='capability contract'` 收敛 `# pass 4 # fail 0`;已包含在上述 CI 全绿内。 + - **runtime 直跑取证 10/10**(绕开本机 runner,逐次 spawn 有限重试):plan 预览 `missingCommands`、兼容产物放行(CURRENT 6.3.0→7.0.0)、越权产物 execute `exit 1 ARTIFACT_INCOMPATIBLE`、拒绝后 `CURRENT` 不动且无 `shadow-dev-workflow-9.9.9` 残留目录、link 轨拒绝且 `LINK` 不写、`status` 三元组 `artifactVersion/cliVersion/missingCommands`、zh 拒绝指引走 stderr 且 stdout 的 code 不本地化。 + - **本机限制(非代码缺陷)**:`npm test` 全量在本机 240s 报警 ×2 不收敛,spec reporter 显示 0 条用例回报完成、卡死在 git-fixture 前置组;抽跑期失败签名全部是 `status:null`/`JSON.parse('')`(子进程被 139 打死),无一条断言值不符。已作为待写回信号提交 review。 +- 交付: issue #46 · PR #47(`feature/20261008-feature-artifact-capability-contract`) +- 信号提案(review 第 9 项,**随本 change 输出但不在本 change 落文件**——CLI 仓尚无 `shadow-docs/signals.md`,建档需独立小 change,遵守「只修改 brief 声明文件」): + 1. negative · 权重 3 · 深度 runtime · 域/scope:验证工具链 · 本仓测试。`node --test` 全量在开发机不可信:139 段错误使被 spawn 的 CLI 子进程返回 `status:null`(表现为 `JSON.parse('')` 假失败),runner 还可能整体挂死(spec reporter 0 用例回报)。**且绝不要用 `out=$(node --test …)` 收集**——子进程被信号打死时孤儿子进程仍持有管道写端,命令替换永不返回(本 change 实踩两次,各白等约 5 分钟)。改法:`perl -e 'alarm N; exec @ARGV'` 加界 + 输出重定向到文件 + 全量回归以 CI `test.yml` 为准。退役条件:换机/Node 升级后 139 消失,或测试改为不 spawn 子进程。 + 2. negative · 权重 2 · 深度 unit · 域/scope:CLI 自身开发 · 命令入口。`shadow-dev` shim 解析到**已装版本**(本次 1.4.0),不是工作树代码——1.4.0 缺 PR #45「execute 端点零 dirty」,用它跑 `commit` 会残留脏 brief,需额外一条 commit 收口。改法:本仓自测一律 `node cli.mjs …`,或先 `bash scripts/install-cli.sh link <本仓>` 把 shim 钉到工作树。退役条件:发布 v1.5.0 且本机 shim ≥ v1.5.0。 + 3. negative · 权重 2 · 深度 unit · 域/scope:Knowledge/brief 编辑 · `shadow-docs/changes/*/brief.md`。brief 正文与 frontmatter 的 `workflow.issuePlan.body` 是**同一份散文的两处副本**,对任务行做字符串替换会先命中快照(文件里快照在正文之前);本 change 实踩过一次。改法:替换须锚定 frontmatter 之外的正文区间,改完再跑一次 `issue plan` 让快照按正文重渲染。退役条件:issue 快照不再内嵌正文全文。 + +## 知识评估 + +- **预期影响:** 更新 +- **候选卡片:** `shadow-docs/knowledge/install-distribution.md` +- **理由:** 卡片已登记「workflow 产物消费契约三件必备 + pack 清单与消费方契约两处同源」,本变更把消费契约扩展为「三件必备 + 可选能力声明 `requiresCommands`,缺省兼容」,并新增「物化前能力断言不可绕过」的执行约束——属既有稳定事实的原位演进,不另立新卡(knowledge-cards:能更新不新增)。更新时补 `verified-depth: runtime` 与观察点(隔离前缀下拒绝/放行两路径的指针字节与错误输出)。跨项目经验(「内容产物自声明能力、安装器断言,替代静态版本 pin」)待本 change 落地并验证后,再评估是否上浮进 workflow 仓 `knowledge/`。 diff --git a/shadow-docs/knowledge/install-distribution.md b/shadow-docs/knowledge/install-distribution.md index 2c4322c..ea1c5ee 100644 --- a/shadow-docs/knowledge/install-distribution.md +++ b/shadow-docs/knowledge/install-distribution.md @@ -1,8 +1,8 @@ --- title: shadow-dev CLI 安装与分发模型 domain: install-distribution -keywords: [安装, installer, shim, 指针文件, LINK, CURRENT, 双轨, 回滚, 插件钩子, tarball, Git Bash, PowerShell, workflow 域, bind, adapters, sidecar, bootstrap] -scope: [scripts/install-cli.sh, scripts/bootstrap.sh, lib/domains/workflow.mjs, lib/domains/bind.mjs, test/install.test.mjs, test/cli.test.mjs] +keywords: [安装, installer, shim, 指针文件, LINK, CURRENT, 双轨, 回滚, 插件钩子, tarball, Git Bash, PowerShell, workflow 域, bind, adapters, sidecar, bootstrap, requiresCommands, 能力契约, missingCommands, ARTIFACT_INCOMPATIBLE, 版本错位] +scope: [scripts/install-cli.sh, scripts/bootstrap.sh, lib/domains/workflow.mjs, lib/domains/bind.mjs, lib/commands.mjs, test/install.test.mjs, test/cli.test.mjs] status: active source: - changes/20260917-feature-install-cli-script/brief.md @@ -12,16 +12,19 @@ source: - changes/20260918-fix-cross-platform-ci/brief.md - changes/archive/20260925-feature-workflow-domain/brief.md - changes/20261008-fix-ci-bind-tar-harness/brief.md + - changes/20261008-feature-artifact-capability-contract/brief.md - shadow-dev-workflow 仓 changes/archive/20260925-feature-pack-release/brief.md(跨仓产物契约) - shadow-dev-workflow 仓 changes/archive/20260925-fix-pack-adapters/brief.md(跨仓产物契约) -verified: 2026-10-08 +verified: 2026-10-09 +verified-depth: runtime +verified-scope: 隔离 `SHADOW_WORKFLOW_PREFIX` 下用 fixture 产物真实物化——兼容产物 `workflow execute` 退出 0 且 `CURRENT` 落版本;声明 `requiresCommands:["nope.does-not-exist","zzz.future.command"]` 的产物 execute 退出 1 且 stderr 报 `ARTIFACT_INCOMPATIBLE`,`CURRENT` 字节不变、无 `shadow-dev-workflow-9.9.9` 目录残留;`workflow link --dir` 同拒绝且 `LINK` 不写;`workflow status` 回 `artifactVersion/cliVersion/missingCommands`;CI `test.yml` 9/9(3 OS × node 20/22/24,run 37864139301) --- # shadow-dev CLI 安装与分发模型 ## 当前结论 -`scripts/install-cli.sh` 是唯一安装入口,维护**双轨**:release 物化轨(拉 GitHub Release tarball → 校验 → 物化到 `$PREFIX/shadow-dev-cli-/` → 切 `CURRENT` 指针,`PREVIOUS` 供回滚)与 link 直通轨(`link ` 校验目标目录后把其绝对路径写入 `$PREFIX/LINK`,代码即改即生效,面向 CLI 开发者本人)。`$BIN/shadow-dev`(.sh/.cmd) 是托管 shim,运行时按 `LINK → CURRENT` 两段解析——任何更新、回滚、双轨切换都不改 shim 文件本体。`install --json` 单行输出是 shadow-dev-workflow 插件钩子的跨仓接缝契约。生态分发反转后,CLI 另管**第二产物**:`workflow` 域以同构双轨物化 shadow-dev-workflow 产物(release tarball → `$PREFIX/shadow-dev-workflow-/` → CURRENT/PREVIOUS/current 解析,解析序 LINK → CURRENT),`bind` 域按产物内 `adapters/.json` 描述符把 skills 复制进宿主发现目录并以 sidecar(`.shadow-dev-workflow.json`)记托管清单——**新增宿主 = 产物加一个描述符,CLI 零改动**。`scripts/bootstrap.sh` 编排三段装机(CLI → workflow → bind)。两域均为无 brief 域,`--plan-hash` 是 execute 的唯一凭证,任意目录可用。 +`scripts/install-cli.sh` 是唯一安装入口,维护**双轨**:release 物化轨(拉 GitHub Release tarball → 校验 → 物化到 `$PREFIX/shadow-dev-cli-/` → 切 `CURRENT` 指针,`PREVIOUS` 供回滚)与 link 直通轨(`link ` 校验目标目录后把其绝对路径写入 `$PREFIX/LINK`,代码即改即生效,面向 CLI 开发者本人)。`$BIN/shadow-dev`(.sh/.cmd) 是托管 shim,运行时按 `LINK → CURRENT` 两段解析——任何更新、回滚、双轨切换都不改 shim 文件本体。`install --json` 单行输出是 shadow-dev-workflow 插件钩子的跨仓接缝契约。生态分发反转后,CLI 另管**第二产物**:`workflow` 域以同构双轨物化 shadow-dev-workflow 产物(release tarball → `$PREFIX/shadow-dev-workflow-/` → CURRENT/PREVIOUS/current 解析,解析序 LINK → CURRENT),`bind` 域按产物内 `adapters/.json` 描述符把 skills 复制进宿主发现目录并以 sidecar(`.shadow-dev-workflow.json`)记托管清单——**新增宿主 = 产物加一个描述符,CLI 零改动**。`scripts/bootstrap.sh` 编排三段装机(CLI → workflow → bind)。两域均为无 brief 域,`--plan-hash` 是 execute 的唯一凭证,任意目录可用。分发权威由「workflow 插件 pin CLI」反转为「CLI 驱动内容产物」后,兼容判定不再依赖静态版本号,而由**产物能力契约**承担:产物 `package.json` 可声明 `requiresCommands`(它需要的命令键列表),CLI 在物化/直通落盘前拿自身命令目录(`lib/commands.mjs` 的 `COMMANDS`,即命令面单一事实源)断言,能力不齐即响亮拒绝且指针不动。 ## 执行约束 @@ -32,6 +35,8 @@ verified: 2026-10-08 - Windows 上 `LINK` 落盘存 `cygpath -w` 的 Windows 形态(`.cmd` shim 用 `set /p` 直读,POSIX 形态 node 打不开);shim `.cmd` 用 goto 两段分支而非括号块(括号块内 `%errorlevel%` 提前展开会吞掉真实退出码)。 - 信任边界分轨表述:release 轨 HTTPS + GitHub 仓库(无独立校验和),link 轨目标是用户显式给出的本机目录——引入 link 不扩大下载面,也不得把 link 目标喂给任何网络请求。 - workflow 产物消费契约三件必备:`marketplace.json`/`package.json`/`skills/`;`adapters/` 自 v6.3.1 起必备(bind 依赖),pack 清单与消费方契约两处同源登记。 +- **产物能力契约(第四件,可选声明)**:`requiresCommands` 缺失或非数组 = 旧产物,一律视为兼容(零破坏);声明了但当前 CLI 的命令目录里没有对应键 → `ARTIFACT_INCOMPATIBLE`(退出码 1,与 `ARTIFACT_INVALID` 的 3 类语义分开:前者是「内容比 CLI 新」的校验失败,后者是产物结构损坏)。 +- **能力断言不可绕过且必须早于任何写盘**:`workflow execute` 在 `requireArtifact`/`verOf` 之后、`rmSync`/`mkdirSync`/`cpSync` 之前断言,`workflow link` 在写 `LINK` 之前断言——拒绝路径必须满足「`CURRENT`/`PREVIOUS`/`LINK` 字节不变 + 不产生半成品版本目录」,与既有「冒烟不过则指针不动」同一安全语义。观测与预览分工:`workflow plan` 仅对可离线解析的来源(`--from` 目录/tarball)透出 `missingCommands` 预览(下载轨恒 `null`),预览**不拦**、由 execute 拦;`workflow status` 恒不抛,暴露 `artifactVersion`/`cliVersion`/`missingCommands` 三元组供引导页与巡检读一个信号。跨仓演进顺序**消费方先行兼容、生产方随后声明**:CLI 先支持断言并发布,workflow 仓才删 `cliVersion` pin / SessionStart hook 并声明需求,否则中途「内容要求未发布命令」无任何机制兜住(20261008 实证:pin v1.4.0、skills 已指挥 worktree 域、而该域只在 main 未发布)。 - bind 绝不改 SKILL.md 字节:托管凭 sidecar,非托管同名目录在 plan 标记 blocked、execute 拒绝(exit 1),unbind 按 sidecar 逆操作;`readdirSync` 产出的 entries/sidecar keys 必须排序,保证 planHash 与 sidecar 字节确定。 - 大小写不敏感文件系统(macOS/Windows 默认)上,指针文件与入口软链命名不得仅大小写不同——`CURRENT` 指针文件与 `current` 软链同路径互删(已删软链,统一运行时解析 resolvedRoot)。 - **release 不作为 brief task**:review execute 的机械门禁要求全部任务勾选,而发布天然在 review 之后——把发布写成 task 会造成死锁(20260925-feature-pack-release 教训,发布放合并后/独立环节)。 @@ -43,7 +48,9 @@ verified: 2026-10-08 ## 验证方式 -`node --test test/install.test.mjs` 全绿即契约成立(8 用例:离线物化+shim 运行、幂等/--force、非托管 shim 保护、回滚往返、坏产物、link 映射/优先/回退、link 校验与 guard、dry-run+status+参数拒绝)。shim 面是**平台分支**的:`.sh` 全平台生成,`.cmd` 仅 MINGW/MSYS/CYGWIN——测试断言 `.cmd` 必须带 `platform() === 'win32'` 平台门,恒真/恒假的跨平台断言会让 CI 门禁失真(历史上 main 因此连红一天)。手工复验:`bash scripts/install-cli.sh link <本仓库>` 后 `shadow-dev change list --archived` 立即可用且 `install --json` 后仍走 link(LINK 优先);`unlink` 后回物化版本;`status --json` 的 `linked`/`current` 如实反映。 +`node --test test/install.test.mjs` 全绿即契约成立(8 用例:离线物化+shim 运行、幂等/--force、非托管 shim 保护、回滚往返、坏产物、link 映射/优先/回退、link 校验与 guard、dry-run+status+参数拒绝)。 + +能力契约面:`node --test test/cli.test.mjs` 的 `workflow capability contract:` 四条用例(拒绝路径含指针/残留断言与 stderr `--lang zh` 断言、缺省兼容回归钉、status 三元组投影、link 轨拒绝)全绿,且 `workflow plan is stable and execute materializes versioned layout with pointers`、`workflow link track wins over CURRENT and unlink restores it` 两条既有用例保持绿。手工复验(隔离 `SHADOW_WORKFLOW_PREFIX`):造一个 `package.json` 带 `requiresCommands: ["nope.does-not-exist"]` 的 fixture 产物,`workflow plan` 应 `ok:true` 且 `data.missingCommands` 含该键,`workflow execute` 应退出 1 报 `ARTIFACT_INCOMPATIBLE` 且 `CURRENT` 未变、无对应版本目录,`workflow link --dir` 同样拒绝且不写 `LINK`,`workflow status` 的 `artifactVersion/cliVersion/missingCommands` 如实回显。**本机注意**:`node --test` 全量在开发机不可靠(139 段错误风暴使 spawn 出的 CLI 子进程返回 `status:null`,runner 还可能整体挂死),全量回归以 CI `test.yml`(3 OS × node 20/22/24)为准;单用例隔离重跑即可收敛。shim 面是**平台分支**的:`.sh` 全平台生成,`.cmd` 仅 MINGW/MSYS/CYGWIN——测试断言 `.cmd` 必须带 `platform() === 'win32'` 平台门,恒真/恒假的跨平台断言会让 CI 门禁失真(历史上 main 因此连红一天)。手工复验:`bash scripts/install-cli.sh link <本仓库>` 后 `shadow-dev change list --archived` 立即可用且 `install --json` 后仍走 link(LINK 优先);`unlink` 后回物化版本;`status --json` 的 `linked`/`current` 如实反映。 ## 关联知识 diff --git a/shadow-docs/menu.md b/shadow-docs/menu.md index 90bd3c3..8a7db6e 100644 --- a/shadow-docs/menu.md +++ b/shadow-docs/menu.md @@ -9,6 +9,6 @@ | brief 读写 | brief frontmatter 行尾 CRLF autocrlf BRIEF_FRONTMATTER_REQUIRED planHash | knowledge/brief-frontmatter-crlf.md | | CLI 输出面 | stdout stderr JSON 契约 语言 i18n 本地化 nextStep help 提示 QUIET --full 概览 摘要 减噪 TTY --json SHADOW_DEV_JSON | knowledge/cli-output-contract.md | | plan/execute 凭证 | planHash hash 漂移 norm changedFiles 脏工作区 porcelain trim 凭证链 | knowledge/plan-credential-chain.md | -| 安装与分发 | install 安装器 shim 指针文件 LINK CURRENT 双轨 回滚 插件钩子 tarball Git Bash PowerShell | knowledge/install-distribution.md | +| 安装与分发 | install 安装器 shim 指针文件 LINK CURRENT 双轨 回滚 插件钩子 tarball Git Bash PowerShell requiresCommands 能力契约 missingCommands ARTIFACT_INCOMPATIBLE 版本错位 | knowledge/install-distribution.md | | issue 正文结构 | issue 正文 渲染器 issue-render issuePlan metadata 机器通道 bodySha256 sections 前缀 最小投影 统一结构 | knowledge/issue-body-contract.md | | brief 最终态提交 | 零 dirty 最终态 补提交 briefCommit pushed 空提交 archive push 断点续跑 | knowledge/brief-state-commit.md | diff --git a/test/cli.test.mjs b/test/cli.test.mjs index e23ce57..b8a7fb7 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto' import { execFileSync, spawn, spawnSync } from 'node:child_process' import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { platform, tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' @@ -948,10 +948,10 @@ test('commit execute reuses persisted files and message without re-passing them' // ---- workflow / bind 域:生态分发(无 brief 域,--plan-hash 是唯一凭证;任意目录可用)---- -function buildArtifact(dir, version, { adapters = true, skills = ['shadow-dev-propose', 'shadow-dev-apply'] } = {}) { +function buildArtifact(dir, version, { adapters = true, skills = ['shadow-dev-propose', 'shadow-dev-apply'], requires = null } = {}) { mkdirSync(join(dir, 'skills'), { recursive: true }) writeFileSync(join(dir, 'marketplace.json'), '{"name":"shadow-dev-workflow-local","plugins":[]}') - writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'shadow-dev-workflow', version })) + writeFileSync(join(dir, 'package.json'), JSON.stringify(requires ? { name: 'shadow-dev-workflow', version, requiresCommands: requires } : { name: 'shadow-dev-workflow', version })) for (const s of skills) { mkdirSync(join(dir, 'skills', s), { recursive: true }) writeFileSync(join(dir, 'skills', s, 'SKILL.md'), `---\nname: ${s}\n---\n\n# ${s}\n`) @@ -1553,3 +1553,87 @@ test('zero-dirty: archive final state (status archived + INDEX entry) is what re assert.match(execFileSync('git', ['show', 'main:shadow-docs/INDEX.md'], { cwd: remote, encoding: 'utf8' }), /archive\/sample\/brief\.md/) } finally { api.close() } }) + +// ---- 产物能力契约(20261008-feature-artifact-capability-contract)---- +// 分发权威反转给 CLI 后,「内容要求未发布命令」不再有静态 pin 兜底: +// 产物用 requiresCommands 自声明需求,CLI 在物化落盘前拿自身 COMMANDS 目录断言。 + +test('workflow capability contract: execute refuses an artifact requiring commands this CLI lacks', () => { + const root = mkdtempSync(join(tmpdir(), 'wf-cap-')) + const env = wfEnv(root) + const prefixDir = join(root, 'wf-prefix') + // 现场:先装一个兼容版本,拒绝路径必须证明已有指针不动 + const good = buildArtifact(join(root, 'good'), '6.3.0') + const hg = JSON.parse(run(['workflow', 'plan', '--from', good, '--json'], root, env).stdout).planHash + assert.equal(run(['workflow', 'execute', '--from', good, '--plan-hash', hg, '--confirm', '--json'], root, env).status, 0) + const currentBefore = readFileSync(join(prefixDir, 'CURRENT'), 'utf8') + + // 合成命令键:断言不依赖任何具体发布版本——目录里永远不该有这两个键 + const bad = buildArtifact(join(root, 'bad'), '9.9.9', { requires: ['nope.does-not-exist', 'zzz.future.command'] }) + const planned = run(['workflow', 'plan', '--from', bad, '--json'], root, env) + assert.equal(planned.status, 0, `plan previews incompatibility without failing: ${planned.stderr}`) + assert.deepEqual(JSON.parse(planned.stdout).data.missingCommands, ['nope.does-not-exist', 'zzz.future.command']) + + const hb = JSON.parse(planned.stdout).planHash + const refused = run(['workflow', 'execute', '--from', bad, '--plan-hash', hb, '--confirm', '--json'], root, env) + assert.equal(refused.status, 1, refused.stdout) + assert.equal(JSON.parse(refused.stdout).error.code, 'ARTIFACT_INCOMPATIBLE') + assert.match(JSON.parse(refused.stdout).error.message, /zzz\.future\.command/) + assert.equal(readFileSync(join(prefixDir, 'CURRENT'), 'utf8'), currentBefore, 'CURRENT untouched on refusal') + assert.equal(existsSync(join(prefixDir, 'shadow-dev-workflow-9.9.9')), false, 'no half-materialized version dir') + // 人用层:拒绝与升级指引走 stderr,语言显式钉 zh(CI runner locale 会改渲染语言) + const human = run(['workflow', 'execute', '--from', bad, '--plan-hash', hb, '--confirm', '--lang', 'zh'], root, env) + assert.match(human.stderr, /ARTIFACT_INCOMPATIBLE/) + + // 能力齐备的产物必须放行(放在拒绝路径断言之后,避免污染「指针不动」的现场) + const okArtifact = buildArtifact(join(root, 'ok'), '7.0.0', { requires: ['worktree.inspect'] }) + const hOk = JSON.parse(run(['workflow', 'plan', '--from', okArtifact, '--json'], root, env).stdout).planHash + assert.equal(run(['workflow', 'execute', '--from', okArtifact, '--plan-hash', hOk, '--confirm', '--json'], root, env).status, 0) + const st = JSON.parse(run(['workflow', 'status', '--json'], root, env).stdout).data + assert.equal(st.artifactVersion, '7.0.0') + assert.deepEqual(st.missingCommands, []) +}) + +test('workflow capability contract: artifacts without requiresCommands stay installable (default-compatible)', () => { + const root = mkdtempSync(join(tmpdir(), 'wf-cap-def-')) + const env = wfEnv(root) + const artifact = buildArtifact(join(root, 'artifact'), '6.4.0') + const hash = JSON.parse(run(['workflow', 'plan', '--from', artifact, '--json'], root, env).stdout).planHash + assert.equal(run(['workflow', 'execute', '--from', artifact, '--plan-hash', hash, '--confirm', '--json'], root, env).status, 0) + assert.equal(readFileSync(join(root, 'wf-prefix', 'CURRENT'), 'utf8').trim(), '6.4.0') +}) + +test('workflow capability contract: status projects artifactVersion, cliVersion and missingCommands', () => { + const root = mkdtempSync(join(tmpdir(), 'wf-cap-status-')) + const env = wfEnv(root) + const cliVersion = JSON.parse(readFileSync(join(dirname(CLI), 'package.json'), 'utf8')).version + const empty = JSON.parse(run(['workflow', 'status', '--json'], root, env).stdout).data + assert.equal(empty.artifactVersion, null, 'nothing installed => no artifact version') + assert.deepEqual(empty.missingCommands, []) + assert.equal(empty.cliVersion, cliVersion) + + const artifact = buildArtifact(join(root, 'artifact'), '6.5.0') + const hash = JSON.parse(run(['workflow', 'plan', '--from', artifact, '--json'], root, env).stdout).planHash + assert.equal(run(['workflow', 'execute', '--from', artifact, '--plan-hash', hash, '--confirm', '--json'], root, env).status, 0) + const installed = JSON.parse(run(['workflow', 'status', '--json'], root, env).stdout).data + assert.equal(installed.artifactVersion, '6.5.0') + assert.deepEqual(installed.missingCommands, []) + + // link 轨同样是能力断言的观测面:直通一个要求缺失命令的产物,status 必须报出来 + const dev = buildArtifact(join(root, 'dev'), '9.9.9', { requires: ['nope.missing'] }) + assert.equal(run(['workflow', 'link', '--dir', dev, '--confirm', '--json'], root, env).status, 1, 'link refuses') + const linked = JSON.parse(run(['workflow', 'status', '--json'], root, env).stdout).data + assert.equal(linked.artifactVersion, '6.5.0', 'CURRENT still resolves') + assert.deepEqual(linked.missingCommands, []) +}) + +test('workflow capability contract: link refuses an artifact requiring commands the CLI lacks', () => { + const root = mkdtempSync(join(tmpdir(), 'wf-cap-link-')) + const env = wfEnv(root) + const prefixDir = join(root, 'wf-prefix') + const dev = buildArtifact(join(root, 'dev'), '9.9.9', { requires: ['nope.does-not-exist'] }) + const refused = run(['workflow', 'link', '--dir', dev, '--confirm', '--json'], root, env) + assert.equal(refused.status, 1, refused.stdout) + assert.equal(JSON.parse(refused.stdout).error.code, 'ARTIFACT_INCOMPATIBLE') + assert.equal(existsSync(join(prefixDir, 'LINK')), false, 'LINK is not written on refusal') +})