bookmark-research 0.5.0-beta.7 → 0.5.0-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/docs/diff-review-2026-10-09.md +36 -0
- package/docs/distribution.md +3 -1
- package/docs/release-notes-0.5.0-beta.8.md +34 -0
- package/hosts/dsh/plugin.js +3 -2
- package/package.json +1 -1
- package/scripts/host_assets.py +7 -2
- package/src/bookmark_index.py +2 -2
- package/src/cli.py +11 -12
- package/src/mcp_server.py +4 -4
- package/src/release.py +12 -10
- package/src/research.py +24 -15
- package/src/source_manager.py +123 -42
- package/src/wiki.py +13 -2
package/README.md
CHANGED
|
@@ -22,13 +22,13 @@ Works in **Codex, Claude Code, Pi and DSH (DeepSeek Harness)**.
|
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
25
|
-
**Current beta (`0.5.0-beta.
|
|
25
|
+
**Current beta (`0.5.0-beta.8`)**, interactive on macOS, Linux and Windows:
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
|
-
npx bookmark-research@0.5.0-beta.
|
|
28
|
+
npx bookmark-research@0.5.0-beta.8 install --source npm:bookmark-research@0.5.0-beta.8
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
This pins both the installer and the content. The `@latest` examples below follow the default channel, which remains `0.5.0-beta.3`; `@beta` currently points to `0.5.0-beta.
|
|
31
|
+
This pins both the installer and the content. The `@latest` examples below follow the default channel, which remains `0.5.0-beta.3`; `@beta` currently points to `0.5.0-beta.8`. Changing only the npx selector does not change the installer's default content source.
|
|
32
32
|
|
|
33
33
|
**Default-channel interactive installation:**
|
|
34
34
|
|
package/README.zh.md
CHANGED
|
@@ -22,13 +22,13 @@
|
|
|
22
22
|
|
|
23
23
|
## 安装
|
|
24
24
|
|
|
25
|
-
**当前测试版(`0.5.0-beta.
|
|
25
|
+
**当前测试版(`0.5.0-beta.8`)**,支持 macOS、Linux、Windows 交互安装:
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
|
-
npx bookmark-research@0.5.0-beta.
|
|
28
|
+
npx bookmark-research@0.5.0-beta.8 install --source npm:bookmark-research@0.5.0-beta.8
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
这条命令同时固定安装器和插件内容。下面的 `@latest` 示例使用默认渠道,目前仍为 `0.5.0-beta.3`;`@beta` 当前指向 `0.5.0-beta.
|
|
31
|
+
这条命令同时固定安装器和插件内容。下面的 `@latest` 示例使用默认渠道,目前仍为 `0.5.0-beta.3`;`@beta` 当前指向 `0.5.0-beta.8`。只改 npx 的版本选择,不会改变安装器默认下载的内容来源。
|
|
32
32
|
|
|
33
33
|
**默认渠道的交互安装:**
|
|
34
34
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Diff 审查与修正记录(2026-10-09)
|
|
2
|
+
|
|
3
|
+
本轮审查从 `a4b7630` 之后的工作树 diff 出发,通过 CodeGraph 定位实现与调用链,交叉检查 DSH 分发、检索、研究并发和 Wiki 证据生命周期。原始改动已单独保存为 `264d910`;其发布文件与 npm `0.5.0-beta.7` 的 120 个文件逐字节一致。
|
|
4
|
+
|
|
5
|
+
DSH 修复的方向正确:发布包具有入口与 `dsh.bundle.patch` 声明,路径从安装位置解析,ESM 范围限制在 DSH 目录,环境变量按明确列表转发。用户已确认当前 DSH 跑通,旧评估报告中的失效状态属于历史记录。本轮没有重新安装或修改用户的 DSH profile。
|
|
6
|
+
|
|
7
|
+
原始 diff 的 561 项测试通过,但交叉审查发现了并发和兼容边界,特别是来源数据保护与证据复核。以下问题均有隔离复现,并已在后续源码提交中修正。
|
|
8
|
+
|
|
9
|
+
| 优先级 | 原始 diff 的问题 | 本轮修正 |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| P1 | 研究取消或生成 incomplete 报告后,慢请求仍提交结果,导致最终报告与状态文件不一致;取消后还可能追加 fallback 请求。 | 提交与 fallback 预算预留都重新检查研究为 active、操作为 pending。已关闭或确认中断的操作拒绝迟到结果。 |
|
|
12
|
+
| P1 | merge 在事务外判断版本相同,同步可在检查后写入独有内容,再被 merge 删除。两个未版本化的 legacy 来源也会因 `None == None` 被误合并。 | 在写事务内重新读取并校验双方有效内容版本;legacy 来源须先同步。 |
|
|
13
|
+
| P1 | remove/merge 提交数据库后删除原目录,可能删掉另一连接刚以相同 source ID 重新导入的快照。 | 提交前将旧快照改名为唯一隔离目录,提交后只清理该目录;异常回滚恢复旧版本且不覆盖并发新版本。 |
|
|
14
|
+
| P1 | 已发布证据的响应制品被修改后,`wiki acknowledge` 可重新计算 hash,把 `invalid_evidence` 清成 current。 | 持研究锁校验上一修订的证据与链接;存在 error 时拒绝复核,保留原校验基线。 |
|
|
15
|
+
| P2 | 并发导入最终分配的 source ID 与提前写出的 manifest ID 不一致。 | 在提交锁内分配最终 ID 后生成 manifest,回放保留该 ID。 |
|
|
16
|
+
| P2 | 删除引用来源后,freshness 为 unknown,但 Wiki 仍显示 current。 | 不可解析的来源产生 `source_input_unavailable`,页面进入 needs_review。 |
|
|
17
|
+
| P2 | 旧版 DSH 包缺少新增的嵌套 `package.json`,无法进入承诺的旧包导出兼容分支。 | 仅在文件缺失时生成 ESM manifest;符号链接和错误文件类型仍拒绝。 |
|
|
18
|
+
| P2 | Codex、Claude、Agent Plugins 导出没有根 `package.json`,握手版本退成 `0+unknown`。 | 读取各分发格式自己的 manifest;存在但损坏的优先 manifest 不被其他文件掩盖。 |
|
|
19
|
+
| P2 | 含 `%`、`_`、反斜杠的画布线索在 SQL 命中后又被丢弃。 | SQL 保留转义,结果归属使用原始查询文本。 |
|
|
20
|
+
| P2 | doctor 泄漏探测文件描述符、可能删除预存同名文件,并把普通文件阻断的目录路径报为可写。 | 使用自动关闭的随机临时文件;遇到非目录路径组件立即报告不可写。 |
|
|
21
|
+
|
|
22
|
+
DSH 的重装提示也已改为明确指定 profile 和版本的原生命令,避免默认 `latest` 指向旧发布。Wiki 复核记录保存本次提交的说明。
|
|
23
|
+
|
|
24
|
+
修正快照清理后,第二轮交叉复核发现了“目录改名后、数据库提交前强制终止进程”的恢复窗口。已补启动恢复:在写事务内,根据数据库仍引用的版本及 manifest/hash 校验恢复目录,保留已有新版本,不复活已删除来源。真实子进程强制退出及后续 partial 导入均纳入验证。
|
|
25
|
+
|
|
26
|
+
验证结果:
|
|
27
|
+
|
|
28
|
+
- 最终本地全量测试:**587 项,0 失败,3 项按条件跳过**,Python 3.9.6 / SQLite 3.43.2。跳过项为原生 Pi/DSH 安装、显式启用的 Codex/npm 集成和全宿主 npm 下载测试;不将其计作本轮宿主安装通过。
|
|
29
|
+
- 独立下载真实 npm beta.5,使用当前导出器生成 DSH 包,删除源副本并搬到含中文、空格和引号的路径后,Skill 和实际 MCP 调用通过。
|
|
30
|
+
- 当前五种导出同样在删除源副本、搬迁后完成真实 MCP initialize,全部报告 beta.7;DSH 解释器、参数和环境转发契约通过。
|
|
31
|
+
- 用户画布样例验收:638 个书签实例、557 个唯一 URL、122 个文件夹、5 个栏目;完整库存分页保留全部 ID,二次同步识别未修改内容,原文件 hash 未变。测试未调用网络研究服务。
|
|
32
|
+
- 合成研究验收正确阻止未读完来源时宣称研究完成。已发布 npm 包的 CLI help、doctor、status 冒烟通过。
|
|
33
|
+
|
|
34
|
+
实现仍有明确边界:取消后已经返回的响应可能留下未引用制品,但不能再进入已结束研究的状态或报告。启动恢复只处理数据库仍引用且校验通过的快照;未引用的 `.removed-*` 残留保守保留,不自动回收。普通异常回滚、并发重新导入、回滚后出现新版本,以及提交前/后强制退出均有回归覆盖。
|
|
35
|
+
|
|
36
|
+
这些审查修正属于 `264d910` 之后的源码提交,**不包含在已经发布的 npm beta.7 中**。本轮只提交并推送 Git,没有重新发布 npm,也没有移动 dist-tag。复现所用本机报告、索引和私人样例未纳入提交。
|
package/docs/distribution.md
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
|
|
20
20
|
发布顺序:修改源码版本并完成 CI,发布已经验证的 npm 包,确认 registry 可取得该版本,再更新 GitHub marketplace 的固定版本。未发布源码不能通过 npm 入口安装;需要先用源码入口测试。
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
候选版本在 `release/` 分支同步三个版本清单并运行 CI。该分支的 registry 集成测试使用当前已发布渠道版本,源码测试、原生安装测试和 npm 打包检查仍验证候选内容;主分支的 registry 测试始终验证 marketplace 固定版本。发布通过 CI 的 npm 包并核验 registry 后,再将候选分支合入主分支,避免默认安装清单提前指向未发布版本。
|
|
23
|
+
|
|
24
|
+
测试版使用 npm 的 `beta` 标签;若创建 GitHub 版本说明,则标记为预发布。不会自动移动 npm 的 `latest` 标签。安装测试版时同时固定安装器与内容来源,例如 `npx bookmark-research@0.5.0-beta.8 install --source npm:bookmark-research@0.5.0-beta.8`。仅将 npx 改为 `@beta` 不会改变默认内容来源 `latest`;已固定的旧版安装也不会自动换成测试版。
|
|
23
25
|
|
|
24
26
|
## 官方资料与项目实例
|
|
25
27
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Bookmark Research 0.5.0 beta 8
|
|
2
|
+
|
|
3
|
+
**Beta / 测试版.** Published through the npm `beta` channel; the `latest` tag remains unchanged.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Pin both the installer and the plugin content:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx bookmark-research@0.5.0-beta.8 install --source npm:bookmark-research@0.5.0-beta.8
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Choose Codex, Claude Code, Pi or DSH in the installer. For an existing DSH profile, the released layer can also be installed directly:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
dsh plugin --profile <name> add bookmark-research@0.5.0-beta.8
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Changes
|
|
20
|
+
|
|
21
|
+
- DSH exports support legacy npm packages that lack the nested ESM manifest. Repair instructions retain the selected profile and release version. The Desktop prerequisite test now resolves actual executable shims on Windows.
|
|
22
|
+
- Relocated Codex, Claude Code and Agent Plugins exports report their own release version during the real MCP handshake.
|
|
23
|
+
- Cancelled or incomplete research tasks reject late provider results and further fallback reservations. Concurrent evidence imports retain the final source IDs in their manifests.
|
|
24
|
+
- Source merging checks matching, nonempty synchronized versions inside the write transaction. Source removal preserves concurrent reimports; startup recovers hash-verified snapshots left by an interrupted removal without reviving committed deletions.
|
|
25
|
+
- Wiki acknowledgements cannot accept invalid evidence. Unavailable sources require review, and a new acknowledgement retains its review note.
|
|
26
|
+
- Canvas searches preserve literal `%`, `_` and backslash characters. Storage diagnostics close probe handles, preserve existing files and report paths blocked by files.
|
|
27
|
+
|
|
28
|
+
## Verification
|
|
29
|
+
|
|
30
|
+
Regression coverage includes research cancellation, source merge/removal races, process-exit recovery, Wiki evidence integrity, exported release identity and DSH installation prerequisites. CI runs the Python suite and npm command smoke checks on Linux, macOS and Windows, plus isolated native Codex installation, migration and update tests.
|
|
31
|
+
|
|
32
|
+
Release branches test registry integration against the currently published package while validating the candidate's source and packed runtime. After publication, the main branch verifies the newly pinned npm version. Personal canvas packages and local evaluation reports are excluded from the published package.
|
|
33
|
+
|
|
34
|
+
Install from [npm](https://www.npmjs.com/package/bookmark-research/v/0.5.0-beta.8). GitHub provides source and release notes; npm is the shared release package for all four hosts.
|
package/hosts/dsh/plugin.js
CHANGED
|
@@ -70,8 +70,9 @@ export function apply(ctx) {
|
|
|
70
70
|
const missing = [cli, fileURLToPath(skillUrl)].filter((path) => !existsSync(path));
|
|
71
71
|
if (missing.length > 0) {
|
|
72
72
|
process.stderr.write("bookmark-research: installation is incomplete; missing " + missing.join(", ") + ".\n"
|
|
73
|
-
+ "Reinstall the
|
|
74
|
-
+ "
|
|
73
|
+
+ "Reinstall the intended version in the affected profile: dsh plugin --profile <profile> add"
|
|
74
|
+
+ " bookmark-research@<version> (use the Desktop app's CLI for profile desktop), or remove the stale"
|
|
75
|
+
+ " registration that points at a deleted install directory.\n");
|
|
75
76
|
return;
|
|
76
77
|
}
|
|
77
78
|
const env = Object.fromEntries(FORWARDED_ENV
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bookmark-research",
|
|
3
|
-
"version": "0.5.0-beta.
|
|
3
|
+
"version": "0.5.0-beta.8",
|
|
4
4
|
"description": "Check bookmark URLs or Canvas packages, compare sources with host-led research, and retain verifiable reports.",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"repository": {
|
package/scripts/host_assets.py
CHANGED
|
@@ -13,13 +13,15 @@ HOST_SOURCE_FILES = (
|
|
|
13
13
|
)
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def read_asset(root, relative):
|
|
16
|
+
def read_asset(root, relative, default=None):
|
|
17
17
|
"""Read a curated asset without following source symlinks."""
|
|
18
18
|
current = Path(root)
|
|
19
19
|
for part in Path(relative).parts:
|
|
20
20
|
current = current / part
|
|
21
21
|
if current.is_symlink():
|
|
22
22
|
raise ValueError("Source symlinks are not exported: " + relative)
|
|
23
|
+
if default is not None and not current.exists():
|
|
24
|
+
return default
|
|
23
25
|
if not current.is_file():
|
|
24
26
|
raise ValueError("Required host asset is missing: " + relative)
|
|
25
27
|
return current.read_bytes()
|
|
@@ -62,7 +64,10 @@ def export_host_assets(source, stage, host):
|
|
|
62
64
|
files["hosts/dsh/plugin.js"] = read_asset(source, "hosts/dsh/plugin.js")
|
|
63
65
|
# Scope ESM to the entry module so a package root may stay CommonJS
|
|
64
66
|
# (the npm package keeps a CommonJS bin and the pi field).
|
|
65
|
-
|
|
67
|
+
# Older releases predate the nested manifest. Supply the scope
|
|
68
|
+
# when absent, while still rejecting links or invalid file types.
|
|
69
|
+
files["hosts/dsh/package.json"] = read_asset(
|
|
70
|
+
source, "hosts/dsh/package.json", default=b'{"type":"module"}\n')
|
|
66
71
|
files["workflows/bookmark-research/meta.json"] = (
|
|
67
72
|
json.dumps(definition["meta"], ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
68
73
|
relative = "hosts/dsh/workflow-call.py"
|
package/src/bookmark_index.py
CHANGED
|
@@ -890,8 +890,8 @@ class BookmarkIndex:
|
|
|
890
890
|
matches = []
|
|
891
891
|
for row in found:
|
|
892
892
|
haystack = ((row.get("label") or "") + "\n" + (row.get("text") or "")).casefold()
|
|
893
|
-
for target
|
|
894
|
-
needle =
|
|
893
|
+
for target in targets:
|
|
894
|
+
needle = target.casefold()
|
|
895
895
|
if needle and needle in haystack:
|
|
896
896
|
matches.append({"target": target, **row})
|
|
897
897
|
return matches
|
package/src/cli.py
CHANGED
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import sqlite3
|
|
8
8
|
import sys
|
|
9
|
+
import tempfile
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
12
|
from bookmark_index import BookmarkIndex
|
|
@@ -28,19 +29,17 @@ def _writable_directory(path):
|
|
|
28
29
|
"""Report whether the nearest existing ancestor of ``path`` accepts a new file."""
|
|
29
30
|
candidate = Path(path).expanduser()
|
|
30
31
|
for parent in (candidate, *candidate.parents):
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
descriptor = os.open(str(probe), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
35
|
-
except OSError as error:
|
|
36
|
-
return {"path": str(candidate), "checked": str(parent), "writable": False,
|
|
37
|
-
"error": error.strerror or str(error)}
|
|
38
|
-
finally:
|
|
39
|
-
try:
|
|
40
|
-
probe.unlink()
|
|
41
|
-
except OSError:
|
|
32
|
+
try:
|
|
33
|
+
if parent.is_dir():
|
|
34
|
+
with tempfile.TemporaryFile(prefix=".bookmark-research-write-probe-", dir=parent):
|
|
42
35
|
pass
|
|
43
|
-
|
|
36
|
+
return {"path": str(candidate), "checked": str(parent), "writable": True, "error": None}
|
|
37
|
+
if parent.exists() or parent.is_symlink():
|
|
38
|
+
return {"path": str(candidate), "checked": str(parent), "writable": False,
|
|
39
|
+
"error": "Existing path component is not a directory"}
|
|
40
|
+
except OSError as error:
|
|
41
|
+
return {"path": str(candidate), "checked": str(parent), "writable": False,
|
|
42
|
+
"error": error.strerror or str(error)}
|
|
44
43
|
return {"path": str(candidate), "checked": None, "writable": False, "error": "no existing parent directory"}
|
|
45
44
|
|
|
46
45
|
|
package/src/mcp_server.py
CHANGED
|
@@ -131,8 +131,8 @@ TOOL_DESCRIPTIONS = {
|
|
|
131
131
|
"research_readiness": "Check retrieval and optional research service readiness before each new web research question. Respects cached/always/manual preferences; refresh forces checks and offline prevents network. Reports missing credentials, catalog reachability and the exact scope of verified auth separately. observed_tools establish host tool visibility, not OAuth login. test_retrieval additionally uses provider quota for a sample search/read; no professional research job is started.",
|
|
132
132
|
"sync_package": "Import a directory, ZIP or single section JSON, retaining a managed snapshot outside the original. Reuse source_id for a moved or partial export of the same canvas; path aliases are remembered. New exports default to snapshot; Git directories default to live monitoring. Existing sources retain their mode. completeness=partial preserves absent cards; complete reconciles a full mirror. Single cards must be partial. Source files are never modified.",
|
|
133
133
|
"source_history": "List saved source versions and their complete managed snapshot paths, including data retained from partial imports. Reimport a snapshot path to recover its data and recorded relationships. This does not refresh sources or change existing research inventories.",
|
|
134
|
-
"source_remove": "Delete an imported source, its indexed rows and its managed snapshots. Without confirm=true this only reports what would be removed. Research tasks that froze the source keep their saved inventory but stop resolving its items, and Wiki pages citing those claims
|
|
135
|
-
"source_merge": "Drop a duplicate source whose current content version equals into_source_id, moving its path aliases to the survivor so a later import of the same folder updates it.
|
|
134
|
+
"source_remove": "Delete an imported source, its indexed rows and its managed snapshots. Without confirm=true this only reports what would be removed. Research tasks that froze the source keep their saved inventory but stop resolving its items, and Wiki pages citing those claims require review; prefer source_merge for an identical duplicate.",
|
|
135
|
+
"source_merge": "Drop a duplicate source whose current content version equals into_source_id, moving its path aliases to the survivor so a later import of the same folder updates it. Requires matching synchronized content versions; synchronize legacy sources first. Without confirm=true this only reports the merge.",
|
|
136
136
|
"search_bookmarks": "Search bookmark metadata with literal title/URL/note/tag/folder-path matching and exact SQL scopes. Every whitespace-separated term must occur; three-character and longer terms use the trigram index while shorter ones use LIKE substring matching. Targets have independent totals and pages. Report instances separately from unique_urls: a copy anchor returns another card's shared tree, so per-section totals must not be summed. limit=0 or count_only returns counts without rows; item_types=[\"folder\"] enumerates folders, including the synthetic untitled tree container marked synthetic. Canvas text cards, group labels and edge labels matching a target are reported separately in canvas_matches and never inflate total. Refresh checks live directories by default; snapshots remain usable after the original export disappears. Inspect source.state for pending changes or errors. refresh=false uses the last synchronized index, not an arbitrary historical version.",
|
|
137
137
|
"get_context": "Read section headers, bookmark metadata, folder ancestry, geometric group membership and directed canvas edges. Copy anchors share their primary tree. Refresh is enabled by default.",
|
|
138
138
|
"index_status": "Read source modes, original input availability, saved snapshots, pending files, last check/error, counts and local monitor status. Does not refresh or fetch webpages. unchecked means a live directory has not been checked recently.",
|
|
@@ -316,8 +316,8 @@ TOOL_DESCRIPTIONS.update({
|
|
|
316
316
|
"research_service_attach": "Attach an independently known provider run ID without starting remote work. Can recover a missing create response or reference a run started through an existing host integration. Status remains unverified until observed.",
|
|
317
317
|
"research_service_import": "Import a saved completed provider report into a research session as unreviewed external-report evidence. Original URL coverage is unchanged; read and assess cited originals separately.",
|
|
318
318
|
"wiki_write": "Publish an authored topic/entity page outside source packages, with reviewed active claims, source provenance, relationships and an immutable revision. Supply the current expected_revision when updating. Pass reviewed_input_version to record that the page was checked against that already-synchronized input version, which closes source_input_changed for cited tasks at that version. Semantic support requires the named human/model review.",
|
|
319
|
-
"wiki_acknowledge": "Re-publish a Wiki page unchanged to record that its cited sources were reviewed against their current input version, closing source_input_changed without editing content. Writes an immutable revision and preserves history; the note records who reviewed what. No network access and no semantic scoring.",
|
|
320
|
-
"wiki_get": "Read a Wiki page or an immutable earlier revision with source/claim links resolved at current research locations. validation.status=needs_review signals changed bookmark input after the cited research; this does not automatically revise the page or refetch webpages.",
|
|
319
|
+
"wiki_acknowledge": "Re-publish a Wiki page unchanged to record that its cited sources were reviewed against their current input version, closing source_input_changed without editing content. Writes an immutable revision and preserves history; the note records who reviewed what. Invalid evidence or links must be repaired first, and unavailable bookmark input still requires review. No network access and no semantic scoring.",
|
|
320
|
+
"wiki_get": "Read a Wiki page or an immutable earlier revision with source/claim links resolved at current research locations. validation.status=needs_review signals changed or unavailable bookmark input after the cited research; this does not automatically revise the page or refetch webpages.",
|
|
321
321
|
"wiki_list": "List paginated Wiki pages and current revision metadata without network access.",
|
|
322
322
|
"wiki_search": "Find saved knowledge when the task needs prior research absent from the conversation. Return matching Wiki text, revision dates and validation; needs_review flags changed bookmark input. Invalidated or retracted evidence is excluded. Literal full-text retrieval, not vector search.",
|
|
323
323
|
"wiki_lint": "Check Wiki evidence hashes, active claims, source review states and cross-page links. Reports semantic review declarations separately from mechanical integrity checks.",
|
package/src/release.py
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Release identity from the installed distribution's own manifest.
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
manifest
|
|
7
|
-
|
|
8
|
-
When the manifest is missing (an exotic layout, or a single file copied out of
|
|
9
|
-
the package) the version is reported as unknown rather than guessed, so a
|
|
10
|
-
mismatch stays visible instead of being silently papered over.
|
|
3
|
+
The npm, Pi and DSH packages carry ``package.json``. Codex, Claude and Agent
|
|
4
|
+
Plugins exports retain the release version in their native plugin manifest.
|
|
5
|
+
Read that identity for MCP and HTTP clients instead of hardcoding a version.
|
|
6
|
+
An absent or invalid manifest reports an unknown version; an invalid preferred
|
|
7
|
+
manifest never falls through to a different manifest that might hide damage.
|
|
11
8
|
"""
|
|
12
9
|
|
|
13
10
|
import json
|
|
@@ -32,7 +29,12 @@ def read_manifest_version(path):
|
|
|
32
29
|
@lru_cache(maxsize=1)
|
|
33
30
|
def release_version():
|
|
34
31
|
"""Return this installation's manifest version."""
|
|
35
|
-
|
|
32
|
+
root = Path(__file__).resolve().parents[1]
|
|
33
|
+
for relative in ("package.json", ".codex-plugin/plugin.json", ".claude-plugin/plugin.json", "plugin.json"):
|
|
34
|
+
path = root / relative
|
|
35
|
+
if path.exists() or path.is_symlink():
|
|
36
|
+
return read_manifest_version(path)
|
|
37
|
+
return UNKNOWN_VERSION
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
def user_agent(product="bookmark-research"):
|
package/src/research.py
CHANGED
|
@@ -774,6 +774,14 @@ class ResearchSessions:
|
|
|
774
774
|
references.append({key: bookmark[key] for key in ("source_id", "section_id", "item_id")})
|
|
775
775
|
return [entry["id"] for entry in entries], references
|
|
776
776
|
|
|
777
|
+
@classmethod
|
|
778
|
+
def _pending_operation(cls, state, operation_id):
|
|
779
|
+
cls._active(state)
|
|
780
|
+
operation = cls._find(state["operations"], operation_id, "operation")
|
|
781
|
+
if operation["status"] != "pending":
|
|
782
|
+
raise ValueError("Research operation is no longer pending; its late result was not committed")
|
|
783
|
+
return operation
|
|
784
|
+
|
|
777
785
|
def _operation(self, research_id, operation_id, kind, parameters, cost, execute):
|
|
778
786
|
operation_id = self._identifier(operation_id, "operation_id")
|
|
779
787
|
path = self._path(research_id)
|
|
@@ -823,14 +831,17 @@ class ResearchSessions:
|
|
|
823
831
|
# writes made by another process while the request was in flight survive.
|
|
824
832
|
with self._task_lock(path):
|
|
825
833
|
_, state = self._load(research_id)
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
834
|
+
# Cancellation and explicit interruption take effect immediately.
|
|
835
|
+
# A late response must not change a terminal report or revive its step.
|
|
836
|
+
stored = self._pending_operation(state, operation_id)
|
|
829
837
|
new_sources = payload.pop("sources", [])
|
|
830
838
|
committed = []
|
|
831
839
|
for source in new_sources:
|
|
832
840
|
source = copy.deepcopy(source)
|
|
833
841
|
source["id"] = "s%s" % (len(state["sources"]) + 1)
|
|
842
|
+
if kind == "import_evidence":
|
|
843
|
+
self._write(self._artifact(path, source["manifest_file"]),
|
|
844
|
+
{"schema_version": 1, "kind": "host_evidence_import", "source": source})
|
|
834
845
|
state["sources"].append(source)
|
|
835
846
|
committed.append(source)
|
|
836
847
|
stored["status"] = status
|
|
@@ -875,15 +886,14 @@ class ResearchSessions:
|
|
|
875
886
|
# latest state under the lock instead of trusting the snapshot.
|
|
876
887
|
with self._task_lock(path):
|
|
877
888
|
_, latest = self._load(research_id, path)
|
|
889
|
+
stored = self._pending_operation(latest, operation_id)
|
|
878
890
|
remaining = latest["budget"]["max_search_calls"] - latest["usage"]["search_calls"]
|
|
879
891
|
chosen = jobs[:max(0, remaining)]
|
|
880
892
|
if chosen:
|
|
881
893
|
latest["usage"]["search_calls"] += len(chosen)
|
|
882
|
-
stored
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
stored["fallback_attempts"] = [{"provider": name, "target": target, "query": query}
|
|
886
|
-
for name, target, query in chosen]
|
|
894
|
+
stored["reserved"]["search_calls"] = stored["reserved"].get("search_calls", 0) + len(chosen)
|
|
895
|
+
stored["fallback_attempts"] = [{"provider": name, "target": target, "query": query}
|
|
896
|
+
for name, target, query in chosen]
|
|
887
897
|
self._save(path, latest)
|
|
888
898
|
return chosen
|
|
889
899
|
|
|
@@ -961,6 +971,7 @@ class ResearchSessions:
|
|
|
961
971
|
# so a concurrent writer cannot lose its usage or sources.
|
|
962
972
|
with self._task_lock(path):
|
|
963
973
|
_, latest = self._load(research_id, path)
|
|
974
|
+
stored = self._pending_operation(latest, operation_id)
|
|
964
975
|
remaining = latest["budget"]["max_fetch_calls"] - latest["usage"]["fetch_calls"]
|
|
965
976
|
selected, reserved = [], 0
|
|
966
977
|
for name in candidates:
|
|
@@ -971,10 +982,8 @@ class ResearchSessions:
|
|
|
971
982
|
remaining -= cost
|
|
972
983
|
if selected:
|
|
973
984
|
latest["usage"]["fetch_calls"] += reserved
|
|
974
|
-
stored
|
|
975
|
-
|
|
976
|
-
stored["reserved"]["fetch_calls"] = stored["reserved"].get("fetch_calls", 0) + reserved
|
|
977
|
-
stored["fallback_attempts"] = [{"provider": name, "urls": pending} for name in selected]
|
|
985
|
+
stored["reserved"]["fetch_calls"] = stored["reserved"].get("fetch_calls", 0) + reserved
|
|
986
|
+
stored["fallback_attempts"] = [{"provider": name, "urls": pending} for name in selected]
|
|
978
987
|
self._save(path, latest)
|
|
979
988
|
return selected
|
|
980
989
|
|
|
@@ -1060,7 +1069,7 @@ class ResearchSessions:
|
|
|
1060
1069
|
identifiers, refs = self._source_links(current, url)
|
|
1061
1070
|
if supplied_ids is not None:
|
|
1062
1071
|
identifiers = supplied_ids
|
|
1063
|
-
source = {"
|
|
1072
|
+
source = {"question_id": question_id,
|
|
1064
1073
|
"operation_id": operation_id, "url": url, "canonical_url": canonical,
|
|
1065
1074
|
"provider": provenance.get("provider", "host_import"),
|
|
1066
1075
|
"retrieved_at": provenance.get("retrieved_at"), "imported_at": imported_at,
|
|
@@ -1075,8 +1084,8 @@ class ResearchSessions:
|
|
|
1075
1084
|
"inventory_ids": [] if kind == "external_report" else identifiers,
|
|
1076
1085
|
"referenced_inventory_ids": (supplied_ids or []) if kind == "external_report" else [],
|
|
1077
1086
|
"bookmark_refs": [] if kind == "external_report" else refs}
|
|
1078
|
-
|
|
1079
|
-
|
|
1087
|
+
# The commit assigns the source id and writes its manifest together.
|
|
1088
|
+
# The request snapshot cannot predict ids allocated by other writers.
|
|
1080
1089
|
return {"status": "ok", "sources": [source], "next_action":
|
|
1081
1090
|
"Read and review the saved text. An imported report is one secondary source and does not mark its referenced original pages as read."}
|
|
1082
1091
|
|
package/src/source_manager.py
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import re
|
|
6
7
|
import shutil
|
|
7
8
|
import sqlite3
|
|
8
9
|
import tempfile
|
|
9
10
|
import time
|
|
10
11
|
import uuid
|
|
12
|
+
from contextlib import contextmanager
|
|
11
13
|
from datetime import datetime, timezone
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
|
|
@@ -36,6 +38,60 @@ class SourceManager:
|
|
|
36
38
|
with index.transaction():
|
|
37
39
|
for statement in SCHEMA:
|
|
38
40
|
self.connection.execute(statement)
|
|
41
|
+
self._recover_retired_snapshots()
|
|
42
|
+
|
|
43
|
+
def _recover_retired_snapshots(self):
|
|
44
|
+
"""Recover a removal interrupted before commit, using surviving DB references."""
|
|
45
|
+
if self.storage is None or self.storage.is_symlink() or not self.storage.is_dir():
|
|
46
|
+
return
|
|
47
|
+
retired = {}
|
|
48
|
+
for directory in self.storage.iterdir():
|
|
49
|
+
match = re.fullmatch(r"\.removed-([0-9a-f]{64})-[0-9a-f]{32}", directory.name)
|
|
50
|
+
if match and not directory.is_symlink() and directory.is_dir():
|
|
51
|
+
retired.setdefault(match[1], []).append(directory)
|
|
52
|
+
if not retired:
|
|
53
|
+
return
|
|
54
|
+
for row in self.connection.execute("SELECT source_id FROM sources").fetchall():
|
|
55
|
+
source_id = row[0]
|
|
56
|
+
try:
|
|
57
|
+
canonical = self._snapshot_directory(source_id)
|
|
58
|
+
except ValueError:
|
|
59
|
+
continue
|
|
60
|
+
candidates = retired.get(canonical.name, [])
|
|
61
|
+
if not candidates:
|
|
62
|
+
continue
|
|
63
|
+
references = list(self.connection.execute(
|
|
64
|
+
"SELECT version_id,snapshot_path FROM source_snapshots WHERE source_id=?", (source_id,)))
|
|
65
|
+
try:
|
|
66
|
+
current = self._record(source_id)
|
|
67
|
+
except (ValueError, TypeError):
|
|
68
|
+
current = None
|
|
69
|
+
if isinstance(current, dict):
|
|
70
|
+
references.append((current.get("version_id"), current.get("snapshot_path")))
|
|
71
|
+
for version_id, snapshot_path in references:
|
|
72
|
+
if not isinstance(version_id, str) or not re.fullmatch(r"v-[0-9a-f]{64}", version_id):
|
|
73
|
+
continue
|
|
74
|
+
destination = canonical / version_id
|
|
75
|
+
if (not isinstance(snapshot_path, str) or Path(snapshot_path) != destination / "package"
|
|
76
|
+
or destination.exists() or destination.is_symlink()):
|
|
77
|
+
continue
|
|
78
|
+
for directory in candidates:
|
|
79
|
+
saved = directory / version_id
|
|
80
|
+
if saved.is_symlink() or not saved.is_dir():
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
if any(entry.is_symlink() for entry in saved.rglob("*")):
|
|
84
|
+
continue
|
|
85
|
+
manifest = self._imported_snapshot(read_input(saved / "package"))
|
|
86
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
87
|
+
continue
|
|
88
|
+
if not manifest or manifest.get("source_id") != source_id or manifest.get("version_id") != version_id:
|
|
89
|
+
continue
|
|
90
|
+
canonical.mkdir(exist_ok=True)
|
|
91
|
+
saved.rename(destination)
|
|
92
|
+
break
|
|
93
|
+
# Unreferenced versions and empty retirement directories stay untouched.
|
|
94
|
+
# A committed removal must never be undone just because its cleanup died.
|
|
39
95
|
|
|
40
96
|
def _now(self):
|
|
41
97
|
return datetime.fromtimestamp(self.clock(), timezone.utc).isoformat()
|
|
@@ -296,36 +352,66 @@ class SourceManager:
|
|
|
296
352
|
raise ValueError("Managed snapshot directory is not safe to change")
|
|
297
353
|
return directory
|
|
298
354
|
|
|
355
|
+
@contextmanager
|
|
356
|
+
def _source_removal(self, source_id):
|
|
357
|
+
"""Retire snapshots before commit; clean only this removal's directory."""
|
|
358
|
+
if self.connection.in_transaction:
|
|
359
|
+
raise ValueError("Source remove/merge must run outside another index transaction")
|
|
360
|
+
snapshot = retired = None
|
|
361
|
+
cleanup = {"snapshot_removed": False}
|
|
362
|
+
try:
|
|
363
|
+
with self.index.transaction():
|
|
364
|
+
yield cleanup
|
|
365
|
+
snapshot = self._snapshot_directory(source_id)
|
|
366
|
+
if snapshot is not None and snapshot.is_dir():
|
|
367
|
+
retired = snapshot.with_name(".removed-" + snapshot.name + "-" + uuid.uuid4().hex)
|
|
368
|
+
snapshot.rename(retired)
|
|
369
|
+
except BaseException:
|
|
370
|
+
if retired is not None and retired.is_dir():
|
|
371
|
+
# A failed commit may have released SQLite's lock. Reacquire it
|
|
372
|
+
# and preserve any new snapshot published since that rollback.
|
|
373
|
+
with self.index.transaction():
|
|
374
|
+
self._snapshot_directory(source_id)
|
|
375
|
+
if not snapshot.exists():
|
|
376
|
+
retired.rename(snapshot)
|
|
377
|
+
else:
|
|
378
|
+
# Version directories are immutable. Restore missing old
|
|
379
|
+
# versions without replacing a concurrent import's files.
|
|
380
|
+
for version in retired.iterdir():
|
|
381
|
+
destination = snapshot / version.name
|
|
382
|
+
if not destination.exists():
|
|
383
|
+
version.rename(destination)
|
|
384
|
+
shutil.rmtree(retired)
|
|
385
|
+
raise
|
|
386
|
+
if retired is not None:
|
|
387
|
+
shutil.rmtree(retired)
|
|
388
|
+
cleanup["snapshot_removed"] = True
|
|
389
|
+
|
|
299
390
|
def remove(self, source_id, confirm=False):
|
|
300
391
|
"""Delete a source, its indexed rows and its managed snapshots.
|
|
301
392
|
|
|
302
393
|
Research inventories that froze this source keep their saved input but
|
|
303
394
|
can no longer resolve it, so a caller must confirm explicitly.
|
|
304
395
|
"""
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
with self.index.transaction():
|
|
396
|
+
transaction = self._source_removal(source_id) if confirm else self.index.read_snapshot()
|
|
397
|
+
with transaction as cleanup:
|
|
398
|
+
self._record(source_id)
|
|
399
|
+
counts = self._source_counts(source_id)
|
|
400
|
+
snapshot = self._snapshot_directory(source_id)
|
|
401
|
+
aliases = [row[0] for row in self.connection.execute(
|
|
402
|
+
"SELECT input_path FROM source_aliases WHERE source_id=? ORDER BY input_path", (source_id,))]
|
|
403
|
+
if not confirm:
|
|
404
|
+
return {"source_id": source_id, "status": "confirmation_required", "would_remove": counts,
|
|
405
|
+
"aliases": aliases, "snapshot_directory": str(snapshot) if snapshot else None,
|
|
406
|
+
"impact": "Research tasks that froze this source keep their saved inventory but stop resolving "
|
|
407
|
+
"its items; Wiki pages citing those claims require review. Re-run with confirm=true "
|
|
408
|
+
"(CLI: --confirm) to delete."}
|
|
319
409
|
for table in ("items", "sections", "files", "nodes", "edges", "memberships",
|
|
320
410
|
"source_snapshots", "source_aliases", "source_records"):
|
|
321
411
|
self.connection.execute("DELETE FROM %s WHERE source_id=?" % table, (source_id,))
|
|
322
412
|
self.connection.execute("DELETE FROM sources WHERE source_id=?", (source_id,))
|
|
323
|
-
removed_snapshot = False
|
|
324
|
-
if snapshot is not None and snapshot.is_dir() and not snapshot.is_symlink():
|
|
325
|
-
shutil.rmtree(snapshot)
|
|
326
|
-
removed_snapshot = True
|
|
327
413
|
return {"source_id": source_id, "status": "removed", "removed": counts,
|
|
328
|
-
"aliases": aliases,
|
|
414
|
+
"aliases": aliases, **cleanup}
|
|
329
415
|
|
|
330
416
|
def merge(self, source_id, into_source_id, confirm=False):
|
|
331
417
|
"""Drop a duplicate source whose content version equals the target's.
|
|
@@ -335,23 +421,23 @@ class SourceManager:
|
|
|
335
421
|
"""
|
|
336
422
|
if source_id == into_source_id:
|
|
337
423
|
raise ValueError("source_id and into_source_id must differ")
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
424
|
+
transaction = self._source_removal(source_id) if confirm else self.index.read_snapshot()
|
|
425
|
+
with transaction as cleanup:
|
|
426
|
+
record, target = self._record(source_id), self._record(into_source_id)
|
|
427
|
+
if any(not isinstance(row.get("version_id"), str)
|
|
428
|
+
or not re.fullmatch(r"v-[0-9a-f]{64}", row["version_id"]) for row in (record, target)):
|
|
429
|
+
raise ValueError("Sources need synchronized content versions before merging; sync legacy sources first")
|
|
430
|
+
if record["version_id"] != target["version_id"]:
|
|
431
|
+
raise ValueError("Sources hold different content versions; keep one explicitly with source remove "
|
|
432
|
+
"instead of merging")
|
|
433
|
+
aliases = [row[0] for row in self.connection.execute(
|
|
434
|
+
"SELECT input_path FROM source_aliases WHERE source_id=? ORDER BY input_path", (source_id,))]
|
|
435
|
+
if not confirm:
|
|
436
|
+
return {"source_id": source_id, "into_source_id": into_source_id, "status": "confirmation_required",
|
|
437
|
+
"version_id": record["version_id"], "moved_aliases": aliases,
|
|
438
|
+
"impact": "The duplicate is deleted and its paths re-point at into_source_id; saved research "
|
|
439
|
+
"inventories that froze the duplicate stop resolving. Re-run with confirm=true "
|
|
440
|
+
"(CLI: --confirm) to merge."}
|
|
355
441
|
for alias in aliases:
|
|
356
442
|
self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)", (into_source_id, alias))
|
|
357
443
|
self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)",
|
|
@@ -360,14 +446,9 @@ class SourceManager:
|
|
|
360
446
|
"source_snapshots", "source_aliases", "source_records"):
|
|
361
447
|
self.connection.execute("DELETE FROM %s WHERE source_id=?" % table, (source_id,))
|
|
362
448
|
self.connection.execute("DELETE FROM sources WHERE source_id=?", (source_id,))
|
|
363
|
-
snapshot = self._snapshot_directory(source_id)
|
|
364
|
-
removed_snapshot = False
|
|
365
|
-
if snapshot is not None and snapshot.is_dir() and not snapshot.is_symlink():
|
|
366
|
-
shutil.rmtree(snapshot)
|
|
367
|
-
removed_snapshot = True
|
|
368
449
|
return {"source_id": source_id, "into_source_id": into_source_id, "status": "merged",
|
|
369
450
|
"version_id": record.get("version_id"), "moved_aliases": aliases,
|
|
370
|
-
|
|
451
|
+
**cleanup}
|
|
371
452
|
|
|
372
453
|
def _observation(self, source_id, record, state, **fields):
|
|
373
454
|
record.update(state=state, checked_at=self._now(), **fields)
|
package/src/wiki.py
CHANGED
|
@@ -206,7 +206,11 @@ class WikiStore:
|
|
|
206
206
|
if key not in cache:
|
|
207
207
|
cache[key] = self.research.source_freshness(state=cache[research_id][1])
|
|
208
208
|
freshness = cache[key]
|
|
209
|
-
if freshness
|
|
209
|
+
if freshness.get("state") == "unknown":
|
|
210
|
+
issues.append({"severity": "warning", "code": "source_input_unavailable", "research_id": research_id,
|
|
211
|
+
"message": "The cited bookmark input cannot be checked; restore or synchronize its source before reviewing this page.",
|
|
212
|
+
"source_freshness": freshness})
|
|
213
|
+
elif freshness["requires_review"]:
|
|
210
214
|
# A recorded review of this exact input version closes the
|
|
211
215
|
# warning. A later change produces a new version and reopens it.
|
|
212
216
|
acknowledged = next((row for row in self._acknowledged(record)
|
|
@@ -312,6 +316,11 @@ class WikiStore:
|
|
|
312
316
|
if expected_revision != (previous["revision"] if previous else 0):
|
|
313
317
|
raise ValueError("Wiki revision changed; read the current page before updating")
|
|
314
318
|
prior_record = self._revision(previous) if previous else None
|
|
319
|
+
if acknowledge_all and prior_record is not None:
|
|
320
|
+
# A freshness acknowledgement must preserve the previously
|
|
321
|
+
# published evidence baseline, even if it changed after get().
|
|
322
|
+
if any(issue["severity"] == "error" for issue in self._issues(prior_record, index, cache)):
|
|
323
|
+
raise ValueError("Wiki acknowledgement cannot accept invalid evidence or links; repair them before reviewing input changes")
|
|
315
324
|
acknowledged = self._acknowledgements(references, cache, reviewed_input_version, acknowledge_all,
|
|
316
325
|
page["review"]["note"], prior_record)
|
|
317
326
|
if prior_record is None and len(index["pages"]) >= 5000:
|
|
@@ -361,6 +370,7 @@ class WikiStore:
|
|
|
361
370
|
note = self._text(note, "Acknowledge note")
|
|
362
371
|
current = self.get(page_id)
|
|
363
372
|
revision = current["revision"] if expected_revision is None else expected_revision
|
|
373
|
+
current["page"]["review"]["note"] = note
|
|
364
374
|
result = self.write(page_id, current["page"], "Reviewed against the current input: " + note,
|
|
365
375
|
expected_revision=revision, acknowledge_all=True)
|
|
366
376
|
result["acknowledged_note"] = note
|
|
@@ -424,7 +434,8 @@ class WikiStore:
|
|
|
424
434
|
@staticmethod
|
|
425
435
|
def _validation(issues):
|
|
426
436
|
return {"status": "stale" if any(item["severity"] == "error" for item in issues) else
|
|
427
|
-
"needs_review" if any(item["code"]
|
|
437
|
+
"needs_review" if any(item["code"] in ("source_input_changed", "source_input_unavailable")
|
|
438
|
+
for item in issues) else "current",
|
|
428
439
|
"issues": issues, "semantic_review": "caller_declared; not evaluated by lint"}
|
|
429
440
|
|
|
430
441
|
def get(self, page_id, revision=None):
|