Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .dsh-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,19 @@ SCD 管理但不可恢复,就 `agent.steer(...)` 一条纠正消息,让 Agen
管理,CLI 拒绝对它做 config-dump,但 home 级层对它同样生效。
- 统一只读检查器:`node scripts/verify-install.mjs --platform dsh` 读取
`$DSH_HOME/cordis.patch.yml` 与 `$DSH_HOME/profiles/*/cordis.patch.yml`,
挂载行指向当前源码的 `.dsh-plugin/continuity.mjs` 时 `hooks` 检查为
`PASS`;未挂载时保持 `MANUAL`(skills-only 仍是受支持安装形态)。
仅当静态结构是顶层、无目标 `id` 的 `insert` 列表,直接条目的
`id: thinloop-continuity` 和 `name` 精确指向当前源码 handler,且未禁用时,
`hooks` 检查为 `PASS`。profile patch 与 home patch 按该顺序一起检查;
不把不同 profile 当作叠加层,也不猜测当前运行的是哪个 profile。
`PASS` 仅说明所列 patch 的静态插入配置,**不证明最终组合、插件已经加载
或事件已经执行**;bundle、CLI overlay 和运行时须通过上述组合检查与下面
的行为检查确认。
- 检查器不依赖外部 YAML 包,只读取安装示例使用的 block 列表/映射、普通
或单/双引号标量、布尔值、空 `[]` / `{}` 和注释。无挂载、裸更新行、
重复 id/key、禁用行、嵌套 group、条件字段、无法读取或无效配置,以及
未支持的 YAML(如 flow collection、anchor/alias、tag、block scalar)保持
`MANUAL`,不会凭路径文本猜为成功。任何覆盖操作也保守地要求组合检查;
不支持的有效 YAML 不等于安装错误。skills-only 仍是受支持安装形态。
- 运行时行为:临时目录写入一份 `managed_by` 为 `scd-quickdev` 但缺章节的
`.scd/tasks/current.md`,在该目录运行
`dsh --profile headless "简单任务"`,确认 Agent 停止前被纠正消息打断、
Expand Down
2 changes: 1 addition & 1 deletion config/platform-capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@
},
"verification": {
"mode": "skill-links",
"summary": "十二个 Skill 链接均指向当前源码;新会话的 skill 工具可发现 `scd-next`、`scd-execute`、`scd-project` 与 `scd-quickdev`;只读检查器核对 home 级与 profile 级 `cordis.patch.yml` 中的挂载行,已挂载为 `PASS`,未挂载为 `MANUAL`"
"summary": "十二个 Skill 链接均指向当前源码;新会话的 skill 工具可发现 `scd-next`、`scd-execute`、`scd-project` 与 `scd-quickdev`;只读检查器核对 home 级与 profile 级 `cordis.patch.yml` 中明确启用的根级 `insert`,静态配置通过为 `PASS`,未挂载或组合不确定为 `MANUAL`;不证明运行时加载或事件执行"
}
},
{
Expand Down
7 changes: 7 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ Electron desktop 宿主与全部 agent preset),无需复制 preset;挂载
[`.dsh-plugin/README.md`](../.dsh-plugin/README.md)。DSH 未暴露第三方可用的
压缩前否决点,压缩后仍由 `AGENTS.md` 基线机制重新注入指令。

`node scripts/verify-install.mjs --platform dsh` 只验证上述 `insert` 的静态
结构及精确的 handler 路径,不以注释、裸行或禁用行作为挂载证据。检查器
保守读取文档示例中的 YAML 子集;复杂语法、覆盖操作或不确定组合返回
`MANUAL`。`PASS` 不代表运行时已加载或事件已执行,仍应通过
`dsh --profile web --dump-config` 和插件 README 中的行为步骤核验。


## Evolve 权威源码

`scd-evolve` 诊断阶段不需要源码配置;用户按候选 ID 批准实施后,必须通过本次
Expand Down
2 changes: 1 addition & 1 deletion docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ node evals/knowledge/runner/run.mjs --mode full
| Pi | 十二个 Skill 链接均指向当前源码;Pi RPC `get_commands` 可发现十二个 `/skill:scd-*` 命令 |
| CodeWhale | 十二个 Skill 链接均指向当前源码;`codewhale doctor --json` 确认全局 Skill 根、数量且跳过实时 API 探测 |
| Reasonix | 十二个 Skill 链接均指向当前源码;新会话可通过 `/scd-next`、`/scd-execute`、`/scd-project` 与 `/scd-quickdev` 调用 |
| DeepSeek Harness | 十二个 Skill 链接均指向当前源码;新会话的 skill 工具可发现 `scd-next`、`scd-execute`、`scd-project` 与 `scd-quickdev`;只读检查器核对 home 级与 profile 级 `cordis.patch.yml` 中的挂载行,已挂载为 `PASS`,未挂载为 `MANUAL` |
| DeepSeek Harness | 十二个 Skill 链接均指向当前源码;新会话的 skill 工具可发现 `scd-next`、`scd-execute`、`scd-project` 与 `scd-quickdev`;只读检查器核对 home 级与 profile 级 `cordis.patch.yml` 中明确启用的根级 `insert`,静态配置通过为 `PASS`,未挂载或组合不确定为 `MANUAL`;不证明运行时加载或事件执行 |
| Claude Code | `claude plugin list --json` 提供版本、enabled 与安装路径;检查器从该路径核对十二个 Skill 和两个 Hook,包括 `scd-next` 与 `scd-execute` |
| WorkBuddy | 不验证:WorkBuddy 无可靠只读 CLI 探测;已取消插件页核验要求 |
| ZCode | `zcode plugins list --json` 提供 enabled、version、rootPath、skillCount 与 hookDetails;检查完整 Skill/Hook 载荷和两个可运行 Hook |
Expand Down
111 changes: 111 additions & 0 deletions scripts/dsh-patch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// A deliberately bounded, dependency-free reader for the documented DSH patch
// format. It is NOT a general YAML parser. Unsupported syntax fails closed so
// comments, aliases, tags, folded strings or malformed YAML cannot prove a mount.
export function readDshPatch(text) {
const fail = () => { throw new Error("unsupported or malformed patch YAML; verify with dsh --dump-config"); };
const lines = [];
let ended = false;
let started = false;
for (const raw of text.replace(/^\uFEFF/, "").split(/\r?\n/)) {
if (/[\t\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ufffe\uffff]/.test(raw)) fail();
let quote = null;
let end = raw.length;
for (let i = 0; i < raw.length; i++) {
const char = raw[i];
if (quote === '"' && char === "\\") { i++; continue; }
if (quote === "'" && char === "'" && raw[i + 1] === "'") { i++; continue; }
if (quote) { if (char === quote) quote = null; }
else if (char === '"' || char === "'") quote = char;
else if (char === "#" && (i === 0 || raw[i - 1] === " ")) { end = i; break; }
}
if (quote) fail();
const line = raw.slice(0, end).replace(/ +$/, "");
if (!line) continue;
if (ended) fail();
if (line === "---" && lines.length === 0 && !started) { started = true; continue; }
if (line === "...") { ended = true; continue; }
const indent = /^ */.exec(line)[0].length;
lines.push({ indent, text: line.slice(indent) });
}
if (lines.length === 0) fail();
if (lines.length === 1 && lines[0].text === "[]") return [];
let index = 0;
const scalar = value => {
if (value === "[]") return [];
if (value === "{}") return {};
if (["null", "Null", "NULL", "~"].includes(value)) return null;
if (/^(true|True|TRUE|false|False|FALSE)$/.test(value)) return value.toLowerCase() === "true";
if (/^-?(0|[1-9]\d*)(\.\d+)?$/.test(value)) return Number(value);
if (value.startsWith('"')) {
try { return JSON.parse(value); } catch { fail(); }
}
if (value.startsWith("'")) {
if (!/^'(?:[^']|'')*'$/.test(value)) fail();
return value.slice(1, -1).replaceAll("''", "'");
}
if (!value || /^[?:,\-\[\]{}#&*!|>'"%@`]/.test(value) ||
/[\[\]{}]|:(?:\s|$)|\s[&*!|>]/.test(value)) fail();
return value;
};
function pair(text, indent, target) {
const match = /^([A-Za-z_][\w-]*):(?: +(.*))?$/.exec(text);
if (!match || ["__proto__", "constructor", "prototype", "__jsExpr"].includes(match[1]) || Object.hasOwn(target, match[1])) fail();
const [, key, value] = match;
target[key] = value !== undefined ? scalar(value) :
lines[index]?.indent > indent ? block(lines[index].indent) : null;
}
function mapping(indent, first) {
const result = {};
if (first !== undefined) pair(first, indent, result);
while (index < lines.length && lines[index].indent === indent &&
!/^-(?: |$)/.test(lines[index].text)) {
pair(lines[index++].text, indent, result);
}
return result;
}
function block(indent) {
if (!/^-(?: |$)/.test(lines[index].text)) return mapping(indent);
const result = [];
while (index < lines.length && lines[index].indent === indent &&
/^-(?: |$)/.test(lines[index].text)) {
const text = lines[index++].text;
if (text !== "-" && !/^- [^ ]/.test(text)) fail();
const value = text.slice(1).replace(/^ +/, "");
if (!value) result.push(lines[index]?.indent > indent ? block(lines[index].indent) : null);
else if (/^[A-Za-z_][\w-]*:(?: |$)/.test(value)) result.push(mapping(indent + 2, value));
else result.push(scalar(value));
}
return result;
}
const result = block(lines[0].indent);
if (index !== lines.length || !Array.isArray(result)) fail();
return result;
}

const object = value => value !== null && typeof value === "object" && !Array.isArray(value);

/**
* Recognize unconditional root insertions only. This does not implement Cordis
* composition. Overrides, nested groups, conditional fields and duplicate ids
* require a real composition dump, even if another line names the right handler.
* Passing all applicable profile/home layers makes overrides fail closed too.
*/
export function hasDshInsertion(layers, rowId, handlerNames) {
const ids = new Set();
let found = false;
for (const patches of layers) {
for (const patch of patches) {
if (!object(patch) || Object.keys(patch).length !== 1 ||
!Array.isArray(patch.insert)) return false;
for (const entry of patch.insert) {
if (!object(entry) || typeof entry.id !== "string" || !entry.id ||
typeof entry.name !== "string" || !entry.name || ids.has(entry.id) ||
entry.group || Object.keys(entry).some(key =>
!["id", "name", "disabled", "group", "config"].includes(key))) return false;
ids.add(entry.id);
if (entry.id === rowId && handlerNames.includes(entry.name) && !entry.disabled) found = true;
}
}
}
return found;
}
6 changes: 6 additions & 0 deletions scripts/plugin-list.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Normalize the two observed ZCode CLI shapes without accepting malformed lists. */
export function pluginList(platformId, response) {
if (Array.isArray(response)) return response;
if (platformId === "zcode" && Array.isArray(response?.plugins)) return response.plugins;
return null;
}
5 changes: 3 additions & 2 deletions scripts/refresh-install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { inspectInstallations } from "./verify-install.mjs";
import { pluginList } from "./plugin-list.mjs";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const pluginId = "thinloop@thinloop";
Expand Down Expand Up @@ -98,8 +99,8 @@ export async function refreshInstallation({
}
} else {
const response = JSON.parse(runCommand(platform.verification.command, context));
const plugins = platformId === "zcode" ? response.plugins : response;
const matches = Array.isArray(plugins) ? plugins.filter(entry => entry.id === pluginId) : [];
const plugins = pluginList(platformId, response);
const matches = Array.isArray(plugins) ? plugins.filter(entry => entry?.id === pluginId) : [];
if (matches.length !== 1 || matches[0].enabled !== true) {
throw new Error("Thinloop must already be installed and enabled; no installation or enablement was attempted");
}
Expand Down
51 changes: 30 additions & 21 deletions scripts/verify-install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { isDeepStrictEqual } from "node:util";
import { pluginList } from "./plugin-list.mjs";
import { readDshPatch, hasDshInsertion } from "./dsh-patch.mjs";

const SCRIPT_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
Expand Down Expand Up @@ -364,12 +366,9 @@ function inspectSkillLinks(platform, expected, homeDir, environment) {
}

/**
* Read-only inspection of a Cordis-plugin host mount: the DSH user patch layers
* (`$DSH_HOME/cordis.patch.yml` plus every profile's own `cordis.patch.yml`)
* are scanned for a row naming the source checkout's hook handler. A mounted
* row proves the composition inserts the plugin; no CLI probe is run. An
* absent row stays MANUAL because a skills-only install remains a supported
* state, not a confirmed failure.
* Read-only, static evidence of an unconditional root insert in DSH user patch
* layers. Unsupported YAML/composition stays MANUAL. This cannot establish the
* selected runtime profile, CLI overlays, successful boot or event execution.
*/
function inspectHookMount(platform, expected, context) {
const mount = hookMountDescriptors(platform)[0];
Expand Down Expand Up @@ -408,35 +407,45 @@ function inspectHookMount(platform, expected, context) {
}
}

const mountedIn = [];
const unreadable = [];
const patches = new Map();
const unknown = [];
for (const candidate of candidates) {
let text;
try {
text = fs.readFileSync(candidate, "utf8");
patches.set(candidate, readDshPatch(fs.readFileSync(candidate, "utf8")));
} catch (error) {
if (error?.code === "ENOENT") continue;
unreadable.push(`${candidate}: ${error.message}`);
continue;
}
if (text.includes(handlerPath) || text.includes(handlerUrl)) {
mountedIn.push(candidate);
if (error?.code === "ENOENT") { patches.set(candidate, []); continue; }
unknown.push(`${candidate}: ${error.message}`);
}
}

// Profiles are alternatives, not sequential overlays. Each receives the home
// patch after its own patch. A bare update/unsupported operation in either
// layer prevents that combination from proving an unconditional insertion.
const homePatch = path.join(dshHome, "cordis.patch.yml");
const home = patches.get(homePatch) || [];
const mountedIn = [];
if (unknown.length === 0) {
if (patches.size === 1 && hasDshInsertion([home], mount.row, [handlerPath, handlerUrl])) mountedIn.push(homePatch);
for (const [candidate, patch] of patches) {
if (candidate !== homePatch &&
hasDshInsertion([patch, home], mount.row, [handlerPath, handlerUrl])) {
mountedIn.push(home.length ? `${candidate} + ${homePatch}` : candidate);
}
}
}
if (mountedIn.length > 0) {
return makeCheck(
"hooks",
"PASS",
`${platform.capabilities.hooks.length}/${platform.capabilities.hooks.length} Cordis plugin mounted via ${mountedIn.join(", ")}; loaded at profile boot (restart applies profile patches)`,
`${platform.capabilities.hooks.length}/${platform.capabilities.hooks.length} static Cordis root insert configured via ${mountedIn.join(", ")}; runtime composition, activation and events not verified`,
);
}
return makeCheck(
"hooks",
"MANUAL",
unreadable.length > 0
? `plugin mount unknown (${unreadable.join("; ")}); mount per .dsh-plugin/README.md`
: `plugin not mounted in any ${mount.patchFiles.join(" or ")} under ${dshHome}; skills-only install remains supported — mount per .dsh-plugin/README.md`,
unknown.length > 0
? `plugin mount unknown (${unknown.join("; ")}); mount per .dsh-plugin/README.md`
: `no unconditional root insert verified in ${mount.patchFiles.join(" or ")} under ${dshHome}; skills-only install remains supported; overrides or complex YAML require dsh --dump-config; see .dsh-plugin/README.md`,
);
}

Expand Down Expand Up @@ -727,7 +736,7 @@ function inspectPlugin(platform, expected, runCommand, context) {
let response;
try {
response = JSON.parse(commandResult.stdout);
plugins = platform.id === "zcode" ? response?.plugins : response;
plugins = pluginList(platform.id, response);
} catch {
return singleCheckResult(
platform,
Expand Down
70 changes: 70 additions & 0 deletions tests/dsh-patch.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readDshPatch, hasDshInsertion } from "../scripts/dsh-patch.mjs";

const handler = "file:///tmp/thinloop/.dsh-plugin/continuity.mjs";
const mount = `- insert:\n - id: thinloop-continuity\n name: ${handler}\n`;
const recognizes = text => hasDshInsertion([readDshPatch(text)], "thinloop-continuity", [handler]);

test("DSH bounded reader handles documented block structure and quoted scalars", () => {
assert.deepEqual(readDshPatch(`# comment\n---\n${mount} disabled: false\n config:\n note: 'it''s # a value' # comment\n items:\n - one\n - "two"\n empty: {}\n...\n`), [{ insert: [{
id: "thinloop-continuity", name: handler, disabled: false,
config: { note: "it's # a value", items: ["one", "two"], empty: {} },
}] }]);
for (const text of ["[]", "---\n[]\n..."]) assert.deepEqual(readDshPatch(text), []);
});

for (const [label, text] of [
["empty file", ""],
["comment-only file", "# comment\n"],
["empty document", "---\n...\n"],
["flow-style maps", `- insert: [{ id: thinloop-continuity, name: ${handler} }]`],
["block scalar", `${mount} config: |\n arbitrary text\n`],
["alias", `${mount} config: *settings\n`],
["anchor", `${mount} config: &settings false\n`],
["tag", `${mount} disabled: !!js false\n`],
["broken quote", `${mount} config: "broken\n`],
["duplicate key", `${mount} name: other\n`],
["wrong indentation", `${mount} disabled: true\n`],
["plain scalar trailing colon", `${mount} config: value:\n`],
["non-ASCII indentation", mount.replaceAll(" ", "\u00a0\u00a0\u00a0\u00a0")],
["invalid control character", `${mount} config: \u007f\n`],
["invalid Unicode noncharacter", `${mount} config: bad\ufffevalue\n`],
["tabs", mount.replace(" -", "\t-")],
["second document", `${mount}---\n[]\n`],
["leading empty document", `---\n---\n${mount}`],
["content after end", `${mount}...\n[]\n`],
["multiple spaces after dash", mount.replace("- insert", "- insert")],
["expression object", `${mount} config:\n __jsExpr: does.not.exist()\n`],
["prototype key", `${mount} __proto__:\n disabled: false\n`],
["non-list root", `insert:\n - id: thinloop-continuity\n name: ${handler}\n`],
]) {
test(`DSH bounded reader rejects ${label}`, () => {
assert.throws(() => readDshPatch(text), /unsupported or malformed patch YAML/);
});
}

for (const value of ["false", "False", "FALSE", "null", "~", "0"]) {
test(`DSH disabled ${value} permits a static insertion`, () => {
assert.equal(recognizes(`${mount} disabled: ${value}\n`), true);
});
}
for (const value of ["true", '"false"', "FaLsE", "yes", "1", "false\u00a0"]) {
test(`DSH disabled ${value} cannot prove a mount`, () => {
assert.equal(recognizes(`${mount} disabled: ${value}\n`), false);
});
}

test("DSH config does not act as a host-level enabled flag", () => {
assert.equal(recognizes(`${mount} config: false\n`), true);
assert.equal(recognizes(`${mount} config:\n enabled: false\n`), true);
});

test("DSH unknown entry semantics, nested groups, duplicate ids and overrides fail closed", () => {
for (const suffix of [" group: true\n", " inject: {}\n", " isolate: {}\n", " intercept: {}\n", " unknown: false\n"]) {
assert.equal(recognizes(mount + suffix), false);
}
assert.equal(recognizes(mount + mount), false);
assert.equal(recognizes(`${mount}- id: thinloop-continuity\n disabled: true\n`), false);
assert.equal(recognizes(`- id: group\n insert:\n - id: thinloop-continuity\n name: ${handler}\n`), false);
});
Loading
Loading