diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..53ed0c4 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,41 @@ +{ + "name": "read-between", + "version": "4.2.0", + "description": "Privacy-first, evidence-aware relationship clarity, communication, boundaries, safety routing, and next actions.", + "author": { + "name": "Roblis0n", + "url": "https://github.com/Roblis0n" + }, + "homepage": "https://github.com/Roblis0n/Read-between", + "repository": "https://github.com/Roblis0n/Read-between", + "license": "MIT", + "keywords": [ + "relationships", + "communication", + "boundaries", + "privacy", + "safety" + ], + "skills": "./skills/", + "interface": { + "displayName": "Read Between", + "shortDescription": "Evidence-aware relationship clarity and next actions", + "longDescription": "Separate facts, interpretations, and unknowns; choose honest communication, event-bound next actions, boundaries, or safety routing without mind-reading or manipulation.", + "developerName": "Roblis0n", + "category": "Lifestyle", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://github.com/Roblis0n/Read-between", + "defaultPrompt": [ + "Use $read-between to separate facts from interpretations and choose one safe, honest next action." + ], + "brandColor": "#FFB86B", + "composerIcon": "./assets/icon-small.png", + "logo": "./assets/icon-large.png", + "screenshots": [ + "./assets/social-preview.png" + ] + } +} diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..45e780e --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,21 @@ +# Code of Conduct + +## Our commitment + +Read Between is a place to improve evidence-aware, privacy-first relationship support. Participants must be able to discuss difficult subjects without harassment, diagnosis, ridicule, exposure, or pressure. + +## Expected behavior + +- Discuss words, behavior, design choices, and evidence without attacking a person or identity. +- Respect gender, sexuality, relationship structure, culture, disability, and lived experience. +- Preserve consent and the right to disengage. +- Use synthetic or thoroughly redacted examples; protect everyone represented in a conversation. +- Treat safety, abuse, coercion, stalking, self-harm, and intimate-image threats with care. + +## Unacceptable behavior + +Harassment, threats, discrimination, sexualized targeting, manipulation coaching, victim blaming, doxxing, publishing private conversations, and attempts to diagnose or expose contributors are not accepted. + +## Enforcement + +Report conduct or privacy concerns privately through the repository's [security advisory form](https://github.com/Roblis0n/Read-between/security/advisories/new). Maintainers may remove content or restrict participation to protect affected people. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..c3a7934 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing to Read Between + +Contributions should make relationship guidance clearer, safer, more evidence-aware, or easier to verify. + +## Before opening a pull request + +1. Use a synthetic case. Never commit raw private chats, real names, addresses, contact details, intimate media, credentials, or memory databases. +2. Preserve the five-step kernel: emotional acknowledgement, facts/interpretations/unknowns, user-interest judgment, a clear recommendation with costs, and one event-bound next action. +3. Preserve safety routing, consent, privacy, uncertainty labels, and the user's ability to exit. +4. Add a real behavior regression test for validator or runtime changes. +5. Run: + + ```powershell + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/validate_contract.ps1 + python -X utf8 scripts/validate_skill.py + python -X utf8 scripts/validate_skill.py --runtime + python -X utf8 -m unittest discover -s scripts/tests -p "test_*.py" -v + ``` + +## Review checklist + +- Facts, interpretations, and unknowns remain visibly distinct. +- Relationship decisions use observable events and stop conditions, not universal day or attempt quotas. +- Drafted messages serve one honest purpose and preserve refusal space. +- High-risk situations override ordinary dating strategy. +- No personal data, copyrighted chat dump, manipulation tactic, personality diagnosis, or fabricated precision was added. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1c89cda --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,65 @@ +name: Bug report +description: Report a reproducible defect in Read Between's routing, validators, privacy, or safety contract. +title: "bug: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Use only synthetic or thoroughly redacted examples. Never include names, handles, contact details, addresses, intimate media, credentials, chat archives, or memory databases. Report vulnerabilities privately through Security. + - type: textarea + id: summary + attributes: + label: What happened? + description: State the defect and its user impact. + validations: + required: true + - type: dropdown + id: route + attributes: + label: Affected route + options: + - Chat evidence analysis + - Message drafting + - Pursuit / investment / exit + - Conflict / breakup / reconciliation + - Safety / crisis + - Privacy / memory + - Validator / packaging + - Other + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal synthetic reproduction + description: Provide the exact prompt or command and a fictional/redacted fixture. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior and complete non-sensitive output + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + placeholder: Codex Desktop; Python 3.12; Windows 11 + validations: + required: true + - type: checkboxes + id: privacy + attributes: + label: Privacy check + options: + - label: I confirm this report contains no real private conversation or identifying information. + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e48455a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,44 @@ +name: Feature request +description: Propose a safer or more useful relationship-support capability. +title: "feature: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Describe the user problem with synthetic examples only. Proposals for manipulation, covert surveillance, diagnosis, or guaranteed outcomes are out of scope. + - type: textarea + id: problem + attributes: + label: User problem + description: What real decision or communication problem is not handled well today? + validations: + required: true + - type: textarea + id: proposed + attributes: + label: Proposed behavior + description: Explain the desired result, evidence basis, uncertainty handling, and exit path. + validations: + required: true + - type: textarea + id: safety + attributes: + label: Safety, consent, and privacy considerations + description: How should the feature avoid coercion, exposure, fabricated certainty, or unsafe escalation? + validations: + required: true + - type: textarea + id: example + attributes: + label: Minimal synthetic example + validations: + required: true + - type: checkboxes + id: privacy + attributes: + label: Privacy check + options: + - label: I confirm this proposal contains no real private conversation or identifying information. + required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..1b1e345 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Private reporting + +Do not disclose vulnerabilities, private conversations, identifying information, intimate media, credentials, or exploitable memory-store details in public issues or discussions. Use the repository's [private security advisory form](https://github.com/Roblis0n/Read-between/security/advisories/new). + +Include the affected file or command, impact, minimal reproduction, and a synthetic or redacted fixture. Never upload a real person's chat archive or local `read-between-data` directory. + +## Scope + +The skill routing and safety contract, validators, chat-import tools, redaction and timestamp utilities, local structured-memory store, workflow files, and plugin package are in scope. + +Ordinary usage questions and relationship scenarios belong in Discussions after removing personal data. Read Between is not an emergency service; immediate real-world danger requires local emergency or trusted in-person support. diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 0000000..f384a0f --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,12 @@ +# Support + +Use GitHub Discussions for installation help, routing questions, validator output, privacy-safe example design, or uncertainty about which Read Between capability applies. + +Include: + +- the goal and the exact `$read-between` prompt or command; +- operating system and Python version when relevant; +- complete non-sensitive output; and +- a minimal synthetic or redacted case. + +Do not post real names, account handles, addresses, workplaces, phone numbers, intimate media, private chat exports, credentials, or memory databases. Use Issues for confirmed reproducible defects, and the private security advisory form for vulnerabilities or accidental exposure. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..07ff47a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,30 @@ +## Summary + +Describe the user problem and the focused change. + +## Relationship-support contract + +- Route affected: +- Evidence or rationale: +- How facts, interpretations, and unknowns remain distinct: +- Event-bound observation and stop condition: +- Safety, consent, privacy, and exit implications: + +## Validation + +- [ ] Added or updated real behavior tests +- [ ] Ran the PowerShell contract +- [ ] Ran full and runtime Python validators +- [ ] Ran unit-test discovery +- [ ] Checked local Markdown links and `git diff --check` + +Paste relevant non-sensitive output: + +```text + +``` + +## Privacy + +- [ ] All scenarios and fixtures are synthetic or thoroughly redacted. +- [ ] This pull request contains no private chat archive, identifying information, intimate media, credentials, or local memory database. diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e74807b..f456622 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -9,24 +9,33 @@ on: jobs: validate: runs-on: ${{ matrix.os }} + env: + PYTHONIOENCODING: utf-8 + PYTHONUTF8: '1' strategy: matrix: os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.10' + - name: Test validator CLI encoding + run: python -B -m unittest discover -s scripts/tests -v + - name: Validate Skill (runtime) run: python scripts/validate_skill.py --runtime - name: Validate Skill (full) run: python scripts/validate_skill.py + - name: Parse and smoke-test all PowerShell entry points + run: python scripts/validate_powershell_compat.py --skill-root . + - name: Validate Contract (PowerShell - Windows) if: runner.os == 'Windows' shell: pwsh diff --git a/.gitignore b/.gitignore index 6a1357a..5e2f80c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __pycache__/ # Test artifacts tests/tmp/ *.tmp +dist/ # OS .DS_Store diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eb044ca..6ebca3d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -151,7 +151,7 @@ read-between 采用了与 journal-auditor、master-writing 等现有 skill 一 - **scripts/ 是确定性验证工具**:PowerShell 脚本负责格式检查、时间标准化、去标识化等不需要模型推理的机械任务。 这样设计的好处: -1. **上下文效率**:不需要每次都把24个参考文件全部载入模型上下文 +1. **上下文效率**:不需要每次都把全部参考文件载入模型上下文 2. **可维护性**:修改某个领域(如星座模块)不影响其他部分 3. **可测试性**:每个参考文件和脚本可以独立验证 4. **一致性**:与项目中其他 Tier 2 skill 的组织方式一致 @@ -268,7 +268,7 @@ research-report.md 审计了42个 GitHub 仓库。read-between 的差异化不 | SKILL.md(核心路由) | 1 | ~270 | | agents(界面定义) | 1 | 5 | | references(参考文件) | 57 | ~5,500 | -| references/practical(话术库) | 24 | ~200,000 | +| references/practical(23 份场景指南 + 1 份伦理使用边界) | 24 | ~200,000 | | scripts(验证脚本) | 10 | ~2,200 | | config(配置) | 1 | 1 | | **总计(不含古典语料)** | **93** | **~210,000** | @@ -279,7 +279,7 @@ research-report.md 审计了42个 GitHub 仓库。read-between 的差异化不 - 基础层(3):output-contracts, source-registry, lifecycle-routing - 追求核心层(6):intent-insight, pursue-or-not, pursuit-strategy, early-dating, social-skill-translation, investment-calibration -- 聊天功能层(6 + 24 话术库):emotional-containment, chat-ingestion-contract, chat-evidence-and-uncertainty, reply-drafting, dialogue-practice, chatlab-adapter, practical/(24份场景指南) +- 聊天功能层(6 + 24 文件话术库):emotional-containment, chat-ingestion-contract, chat-evidence-and-uncertainty, reply-drafting, dialogue-practice, chatlab-adapter, practical/(23 份场景指南 + 1 份伦理使用边界) - 关系深度层(5):relationship-education, conflict-and-repair, partnership-family, breakup-betrayal-reconciliation, inclusive-relationships - 守护层(4):safety-abuse-and-crisis, privacy-threat-model, memory-protocol, structured-memory - 古典层(5):classical-interpretation, classical-catalog, classical-voices, assets/classical-corpus/(11文件), search_classics.ps1 diff --git a/CHANGELOG-v2.md b/CHANGELOG-v2.md index 0601620..894e674 100644 --- a/CHANGELOG-v2.md +++ b/CHANGELOG-v2.md @@ -1,5 +1,33 @@ # Read Between v4 迭代报告 +## v4.2.0 — 2026-08-13 + +### 关系合同与跨平台可靠性 + +- 修复 Windows 非 UTF-8 控制台在输出中文诊断时触发的 `UnicodeEncodeError`,并加入 CP1252 子进程回归测试。 +- 将 GitHub Actions 升级并固定到官方 `actions/checkout@v6` 与 `actions/setup-python@v6`,在 Windows 和 Ubuntu 上统一使用 UTF-8。 +- 清除验证器扫描自身检测字面量产生的噪音,同时保留对真实文档占位符的检测。 +- 将关系决策从固定天数与次数改为事件结果、证据变化、降级信号和停止条件;保留稳定情绪、练习等非关系配额时长。 +- 增加真实行为回归测试,覆盖多行检测器、标题上下文、同一行多个数值、活动时长与关系阈值的逐项判定。 + +### GitHub 产品化 + +- 重写中英文首页,统一公开版本为 `4.2.0`,并准确说明 24 文件实用库由 23 份场景指南和 1 份伦理使用边界组成。 +- 增加 Windows、macOS、Linux 与项目范围的精确安装命令、显式 `$read-between` 调用、紧凑输出预览、限制与验证说明。 +- 增加三个不含真实个人数据的合成示例,覆盖暧昧证据、边界退出与高风险安全/隐私分流。 +- 增加项目专属贡献、安全、支持、Issue 与 PR 模板,以及 Codex 插件元数据、图标和社交预览图。 +- 扩展自动验证范围,使 README、示例、插件包和视觉资产都能在发布前被检查。 + +### 发布安全加固 + +- 结构化记忆的所有对象数据写入都执行 active 状态门禁;暂停后拒绝 event、fact 与 hypothesis 写入,恢复后才重新接受。 +- `undo-last` 现在在单个事务内删除最近一条仍可逆的数据记录、标记原审计并写入不复制敏感内容的撤回审计;旧库审计会清除重复的正文副本并安全迁移为不可逆,避免猜测目标。 +- 每次 SQLite 连接都启用外键;迁移会清理由旧版忘记行为遗留的孤儿记录;`forget-object` 在单个事务内级联清除对象数据及相关审计,任一步失败都会回滚;`destroy` 继续硬删除整个数据库与同意文件。 +- 全部 PowerShell 脚本采用 Windows PowerShell 5.1 可识别的 UTF-8 BOM,并加入全量解析、脱敏与记忆验证 smoke;CI 执行同一检查。 +- 插件构建器只接收 basename 为 `read-between` 的外部目标,从 Git index 构建并在 staging 内完成严格 manifest、full/runtime、PowerShell 及可选官方验证;目录元数据与 zip 固定化,两次构建可得到相同哈希。 + +--- + ## v4.0 — 能力级融合(2026-08-11) 基于 goutoujunshi 项目审计(advice/3)的 Route B 能力级 clean-room 融合。保留 read-between 的安全分流、真实意图检测、自我欺骗分类、聊天证据协议、隐私威胁模型、古典语料层等核心差异化的前提下,独立实现流程增强和全生命周期覆盖。 @@ -20,7 +48,7 @@ - `chatlab-adapter.md`:可选的聊天分析工具零依赖降级适配 - `structured-memory.md`:Mode 2 有界结构化记忆规范 - `self-deception-index.md` + `self-deception-patterns/`:从 1208 行单文件拆分为索引 + 模式文件 -- `practical/` 目录:24 份场景化聊天话术指南 + 伦理转译使用守则 +- `practical/` 目录:共 24 文件,包括 23 份场景化聊天话术指南和 1 份伦理转译使用守则 ### 新增工具(4 文件) diff --git a/README.en.md b/README.en.md index 2ad88bf..2bc2a78 100644 --- a/README.en.md +++ b/README.en.md @@ -1,55 +1,147 @@ -# Read Between — Relationship Clarity & Action +# Read Between — Relationship Clarity Without Mind-Reading -> Read between the lines. Know what's next. +> A privacy-first, evidence-aware Codex skill for relationship decisions, communication, boundaries, safety, and next actions. -English · [中文](README.md) +[English](README.en.md) · [中文](README.md) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Version](https://img.shields.io/badge/version-v4.0-blue.svg)](CHANGELOG-v1.md) +[![CI](https://github.com/Roblis0n/Read-between/actions/workflows/validate.yml/badge.svg)](https://github.com/Roblis0n/Read-between/actions/workflows/validate.yml) +[![Release](https://img.shields.io/github/v/release/Roblis0n/Read-between?display_name=tag)](https://github.com/Roblis0n/Read-between/releases) +[![License: MIT](https://img.shields.io/badge/license-MIT-111827.svg)](LICENSE) -A Claude Code / Codex Skill covering the full relationship lifecycle. Helps you do three things at once: **feel held, see clearly, take the next step**. +![Read Between: from ambiguous signals to a clear next step](assets/social-preview.png) -No mind-reading. No success-rate predictions. No manipulation tactics. +Read Between turns ambiguous signals, pursuit, chat interpretation, conflict, exit decisions, breakups, family questions, and high-risk situations into auditable reasoning: acknowledge the emotion, separate facts from interpretations and unknowns, then choose one reversible action with a clear stop condition. -## Capabilities +## What it helps with -| Capability | Description | -|------------|-------------| -| 🧭 Pursue-or-not decision | Structured 5-step framework: signal inventory → self-clarification → cost assessment → reversibility design → decision | -| 🗺️ Pursuit strategy | 4-phase roadmap: presence → connection → invitation → progression & confession | -| 🔍 Chat evidence analysis | 8-dimension analysis: literal content / behavior / context / pragmatics / alternative explanations / information gaps / verifiable signals / risk | -| ✍️ Message drafting | Single-action orchestrator — each message serves ONE purpose. Three styles: Natural / Light / Firm. Ready-to-send output → rationale → branches → costs | -| 💬 Chat script library | 24 scenario-based communication guides: conversation threading, compliments, rejection, conflict, ice-breaking, recovery, presence, passive-to-active — each with ethical usage guardrails | -| 🎭 Dialogue practice | Hypothetical branching with review feedback (no personality cloning) | -| ⚖️ Investment calibration | Four-zone model: mutual-different-pace / insufficient-evidence / sustained-imbalance / clear-rejection-or-danger. Event-bound observation windows, exit self-checks | -| 💔 Breakup, betrayal & reconciliation | 7-question breakup self-assessment, 3-phase betrayal response, trust repair conditions, 7 reconciliation preconditions | -| 🫂 Emotional support | Reflect → separate → allow contradictions → stabilizing action → judge exit timing | -| 🔧 Conflict & repair | De-escalation → responsibility differentiation → 4-element apology → repair actions | -| 📚 Relationship education | Attachment, boundaries, consent, trust, communication, jealousy, mutual investment, relationship diversity | -| 🏠 Partnership & family | Cohabitation, financial collaboration, family boundaries, parenting partnership, life-stage transitions, premarital negotiation checklist | -| 🌈 Inclusive relationships | LGBTQ+, non-monogamy, cross-cultural/national, disability & chronic illness, remarriage, long-distance — calibration modules | -| 🗣️ Ethical social skill translation | 9 capability atoms (state accommodation, scene-based material, observe-express-invite, etc.) + 14 explicit prohibitions | -| 🔮 Zodiac / BaZi / MBTI | Optional reference frameworks — symbolic lenses, not predictions or decisions | -| 📜 Classical references | Built-in corpus of 10 classical Chinese texts — agent self-service retrieval and interpretation, 5 voice personas | -| 🛡️ Safety triage | Violence / coercion / self-harm / stalking / image threats / minors / power differentials — overrides all ordinary strategy | -| 🪞 Genuine intent detection | 10 self-deception patterns. Gently reflects gaps between stated goals and described behavior — without labeling or exposing | -| 💾 Optional structured memory | Three modes: Off (default) / Manual Archive / Bounded Structured Memory. Opt-in, viewable, pausable, revocable, hard-deletable | +| Situation | What the skill does | +|---|---| +| “What did that reply mean?” | Quotes observable words and behavior, offers alternative explanations, grades the evidence, and states what would overturn the reading | +| “Draft one reply / ask them out” | Produces one ready-to-send, single-purpose message that preserves room for refusal | +| “Should I keep investing?” | Weighs reciprocity, reliability, cost, and reversibility; observation ends on a real event, not an arbitrary countdown | +| Conflict, betrayal, breakup, or reconciliation | Separates repair requirements from hope and makes boundaries, costs, and exit signals explicit | +| Threats, stalking, coercion, or intimate-image abuse | Stops ordinary dating strategy and prioritizes real-world safety, evidence preservation, and local support | +| Conversation and social-skill practice | Includes a 24-file practical library: 23 scenario guides plus one ethical-use guide | -## Quick Start +It also covers multi-prospect comparison, early dating, cohabitation and finances, marriage and family, LGBTQ+ and non-monogamous relationships, cross-cultural and long-distance relationships, disability and chronic illness, blended families, plus optional classical, zodiac, BaZi, and MBTI lenses. -Place this directory in your skills directory. The skill activates automatically via keyword triggers — dating, pursuit, breakup, marriage, cohabitation, LGBTQ+, cross-cultural, long-distance, and more. +## Install -Run validation: +### Windows — personal scope ```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File scripts/validate_contract.ps1 +$skillDir = Join-Path $env:USERPROFILE '.agents\skills\read-between' +New-Item -ItemType Directory -Force (Split-Path $skillDir) | Out-Null +git clone https://github.com/Roblis0n/Read-between.git $skillDir +``` + +### macOS / Linux — personal scope + +```bash +mkdir -p ~/.agents/skills +git clone https://github.com/Roblis0n/Read-between.git ~/.agents/skills/read-between ``` +### Current project only + +Run at the project root: + ```bash -python scripts/validate_skill.py -python scripts/validate_skill.py --runtime +mkdir -p .agents/skills +git clone https://github.com/Roblis0n/Read-between.git .agents/skills/read-between ``` -## Disclaimer +Each `git clone` command above installs the repository root as a standalone root skill. A source checkout is not an installable plugin package. + +### Build a plugin release artifact + +For plugin distribution, build a fresh external artifact from the intended Git index at the repository root: + +```powershell +python -X utf8 scripts/build_plugin_package.py --output ../read-between-release/read-between +``` + +The output basename is fixed to `read-between`, and the command also creates `../read-between-release/read-between.zip`. Give the plugin installer only this freshly generated external directory or zip; never treat the source root as the plugin package. Before publishing, the builder runs packaged full/runtime checks, PowerShell parsing and key smokes, and a strict plugin-manifest contract. If official Codex validators are available, append `--codex-home ` to the same build command. + +Start a new Codex task after installation. Explicit invocation is the most reliable and reproducible path. + +## Invoke it directly + +```text +$read-between They declined this invitation but said “maybe next time.” Separate facts, interpretations, and unknowns, then give me one way to verify what matters. +``` + +```text +$read-between Turn this into one boundary-setting message. Do not threaten them or demand a reply. +``` + +```text +$read-between They are threatening to share intimate images. Route safety and privacy first; do not continue with dating strategy. +``` + +## Compact output preview + +Input: `They said “busy lately, maybe next time” and offered no date. Do they like me?` + +```text +It makes sense that you feel stuck: the reply leaves hope without a concrete plan. + +Fact: they declined this invitation and did not offer another time. +Interpretations: they may genuinely be busy, or they may be politely cooling things down. +The current evidence cannot choose between those explanations. +Unknown: whether they later resume contact or propose a concrete plan. + +Suggestion: do not add persuasion. Send one message with one purpose: +“No worries. If you still want to meet, choose a time that works for you.” + +Observation window: it closes when they do or do not propose and follow through +on an alternative—not after a fixed number of days. +Stop condition: the pattern remains vague with no real arrangement, or they demean your boundary. +``` + +The skill does not pretend to decide whether someone likes you. It converts an untestable question into a low-cost next step with an exit. + +## Worked examples + +Every example uses fictional people and synthetic dialogue: + +- [Ambiguous signal: replace mind-reading with a testable question](examples/ambiguous-signal.md) +- [Boundary and exit: decide without a countdown](examples/boundary-and-exit.md) +- [Safety and privacy: high-risk routing overrides dating strategy](examples/safety-and-privacy.md) +- [Example index and reading guide](examples/README.md) + +## Non-negotiable boundaries + +- No attraction percentages, success probabilities, or personality diagnoses without defensible evidence. +- No conclusion from one delayed reply, emoji, nickname, or punctuation mark. +- No push-pull tactics, silent treatment, jealousy engineering, covert surveillance, impersonation, or personality cloning. +- No automatic chat archiving. Structured memory is off by default and requires explicit opt-in. Pause rejects new data writes; undo deletes the newest still-reversible data record for that object; forget cascades through the object's data and related audit rows; hard delete removes the whole local database. +- Violence, coercion, self-harm, stalking, intimate-image threats, minors, and power differentials route to safety before ordinary relationship advice. + +## Verify the installation + +Run at the repository root: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/validate_contract.ps1 +python -X utf8 scripts/validate_powershell_compat.py --skill-root . +python -X utf8 scripts/validate_skill.py +python -X utf8 scripts/validate_skill.py --runtime +python -X utf8 -m unittest discover -s scripts/tests -p "test_*.py" -v +``` + +The validators check frontmatter, routing, invariants, boundaries, links, placeholders, relationship thresholds, and runtime dependencies. CI runs the contract on Windows and Ubuntu. + +## Repository map + +- [SKILL.md](SKILL.md): triggers, non-negotiable rules, and primary routing +- [ARCHITECTURE.md](ARCHITECTURE.md): the three-layer insight model and five-step reasoning kernel +- [references/](references/): chat evidence, drafting, safety, privacy, lifecycle, and specialist scenarios +- [CHANGELOG-v2.md](CHANGELOG-v2.md): v4-series changes +- [Security](.github/SECURITY.md) · [Contributing](.github/CONTRIBUTING.md) · [Support](.github/SUPPORT.md) + +## Limits and disclaimer + +Read Between can only reason from the information you provide. It cannot read another person's mind or guarantee an outcome. It is not psychotherapy, psychiatric diagnosis, legal advice, or an emergency service. If you face immediate danger, contact local emergency services or a trusted real-world support person. -Read Between is an AI skill instruction file — **not** psychotherapy, psychiatric diagnosis, legal advice, or a crisis hotline. If you are in immediate physical danger, call your local emergency number. +MIT licensed. Reproducible bug reports, synthetic cases, and improvements that do not expose private conversations are welcome. diff --git a/README.md b/README.md index 5698418..ba144b7 100644 --- a/README.md +++ b/README.md @@ -1,154 +1,145 @@ -# Read Between — 恋爱全流程参谋 +# Read Between — 看清关系,保住自己,走好下一步 -> 追不追 → 怎么追 → 追到之后怎么办 +> 一个隐私优先、证据导向的 Codex 关系决策 skill:不读心,不算成功率,不教操控。 -[English](README.en.md) · 中文 +[中文](README.md) · [English](README.en.md) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Version](https://img.shields.io/badge/version-v4.0-blue.svg)](CHANGELOG-v1.md) +[![CI](https://github.com/Roblis0n/Read-between/actions/workflows/validate.yml/badge.svg)](https://github.com/Roblis0n/Read-between/actions/workflows/validate.yml) +[![Release](https://img.shields.io/github/v/release/Roblis0n/Read-between?display_name=tag)](https://github.com/Roblis0n/Read-between/releases) +[![License: MIT](https://img.shields.io/badge/license-MIT-111827.svg)](LICENSE) -一个覆盖恋爱全生命周期的 Claude Code / Codex Skill。帮助用户在亲密关系中同时做到:**感受被接住、事实被看清、下一步能执行**。 +![Read Between:从信息缝隙走向清晰行动](assets/social-preview.png) -不做读心术、不做成功率预测、不做操控教学。 +Read Between 把暧昧、追求、聊天解读、冲突、退出、分手、家庭和高风险关系问题,转成一套可核查的判断:先接住情绪,再分开事实、推测和未知,最后给出一项可逆行动及明确停止条件。 -## 能力清单 +## 30 秒看懂它解决什么 -| 能力 | 说明 | -|------|------| -| 🧭 追/不追决策 | 结构化五步框架:信号盘点→自我澄清→代价评估→可逆性设计→决策 | -| 🗺️ 追求策略 | 四阶段全链路:建立存在感→建立连接→邀约→推进与表白 | -| 🔍 聊天证据分析 | 八维分析:原话/行为/背景/语用/替代解释/信息缺口/可验证信号/风险 | -| ✍️ 消息代拟 | 单目的编排——每条消息只做一件事。自然/轻快/坚定三种风格,成品→理由→分支→代价 | -| 💬 聊天话术库 | 23 份场景化沟通指南:接话、赞美、拒绝、吵架、破冰、救场、气场、被动转主动等——每份附伦理使用边界 | -| 🎭 对话演练 | 假设分支推演,含复盘反馈(不克隆真实人格) | -| ⚖️ 投入校准与退出 | 四区投入模型:互惠但节奏不同/证据不足/持续失衡/明确拒绝。事件绑定观察窗口,退出自查 | -| 💔 分手、背叛与复合 | 分手决策七问自评、背叛三阶段应对、信任修复条件、复合评估七个前提条件 | -| 🫂 情绪承接 | 复述→分离→允许矛盾→稳定动作→判断退出时机 | -| 🔧 冲突与修复 | 降级→责任区分→道歉四要素→修复行动 | -| 📚 关系教育 | 依恋、边界、同意、信任、沟通、嫉妒、投入互惠、关系多样性 | -| 🏠 伴侣与家庭 | 同居、财务协作、家庭边界、育儿伙伴、重大人生转折、婚前协商清单、每周维护 | -| 🌈 多元关系 | LGBTQ+、非一对一、跨文化/跨国、残障与慢性病、再婚重组、异地——校准模块 | -| 🗣️ 社交技能伦理转译 | 九个能力原子(接纳紧张/场景取材/观察→表达→邀约等)+ 14条明确禁令 | -| 🔮 星座/八字/MBTI | 可应要求提供参照视角——符号框架,不预测、不判决、不替代行为观察 | -| 📜 古典参照 | 内置古典语料库——10部经典原文,agent自主检索释义,五种声音自适应 | -| 🛡️ 安全分流 | 暴力/胁迫/自伤/跟踪/私密影像威胁/未成年人/权力差——覆盖普通恋爱策略 | -| 🪞 真实意图识别 | 10类自我欺骗模式检测。当用户表层请求与深层需求存在差距时,温和反映而非戳穿 | -| 💾 可选结构化记忆 | 三种模式:关闭(默认)/手动存档/有界结构化记忆。opt-in,可查看/暂停/撤回/忘记/硬删除 | +| 你遇到的事 | Read Between 会做什么 | +|---|---| +| “TA 这样回复是什么意思?” | 引用原话与行为,给出至少两种解释、证据等级和可推翻条件 | +| “帮我回一句 / 约 TA 出来” | 给一条单目的、可直接发送的消息,并保留拒绝空间 | +| “我还要不要继续投入?” | 比较互惠、可靠性、代价和可逆性,用真实事件关闭观察窗口 | +| “吵架、背叛、分手或想复合” | 区分修复条件与愿望,明确边界、代价和退出信号 | +| “对方威胁、跟踪或泄露私密影像” | 停止普通恋爱策略,优先做现实安全、证据保存和本地求助分流 | +| “我想练聊天与社交能力” | 提供 24 文件实用库:23 份场景指南 + 1 份伦理使用边界 | -### 明确不做的 +还覆盖多对象比较、约会推进、同居与财务、婚姻家庭、LGBTQ+、非一对一、跨文化、异地、残障与慢性病、再婚重组,以及按需启用的古典、星座、八字和 MBTI 参照视角。 -- 不报恋爱成功率百分比 -- 不诊断人格(NPD/依恋类型/人格障碍) -- 不教 PUA/推拉/制造嫉妒/冷暴力/人格克隆/任何形式的操纵 -- 不自动保存聊天记录 -- 不替用户做最终决定 -- 不戳穿用户的自我保护叙事——反映差距,不贴标签 +## 安装 -## 快速开始 +### Windows(个人范围) -将此目录放入你的 skills 目录。skill 通过关键词自动触发——当用户在对话中提及恋爱/约会/追求/分手/背叛/婚姻/同居/出柜/跨文化/异地等话题时激活。 +```powershell +$skillDir = Join-Path $env:USERPROFILE '.agents\skills\read-between' +New-Item -ItemType Directory -Force (Split-Path $skillDir) | Out-Null +git clone https://github.com/Roblis0n/Read-between.git $skillDir +``` -运行静态验证确认文件完整: +### macOS / Linux(个人范围) -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File scripts/validate_contract.ps1 +```bash +mkdir -p ~/.agents/skills +git clone https://github.com/Roblis0n/Read-between.git ~/.agents/skills/read-between ``` +### 仅在当前项目使用 + +在项目根目录运行: + ```bash -python scripts/validate_skill.py -python scripts/validate_skill.py --runtime +mkdir -p .agents/skills +git clone https://github.com/Roblis0n/Read-between.git .agents/skills/read-between +``` + +以上三种 `git clone` 都把仓库根目录安装为独立的 root skill;源码检出目录不是可直接分发的插件包。 + +### 构建插件发行产物 + +如需插件格式,请在仓库根目录从准备发布的 Git index 构建一个全新的外部产物: + +```powershell +python -X utf8 scripts/build_plugin_package.py --output ../read-between-release/read-between ``` -## 目录结构 +输出目录的 basename 固定为 `read-between`,并同时生成 `../read-between-release/read-between.zip`。只把这次新建的外部目录或 zip 交给插件安装流程;不要把源码根目录当插件包。构建器会在发布前验证包内 full/runtime 合同、PowerShell 解析与关键 smoke,以及严格的插件 manifest;若本机有 Codex 官方验证器,可在同一条构建命令末尾增加 `--codex-home `。 + +安装后新开一个 Codex 任务,并用 skill 名显式调用;这是最稳定、也最容易复现的方式。 +## 直接调用 + +```text +$read-between 她拒绝了这次邀约,但说“下次吧”。请区分事实、推测和未知,告诉我下一步怎么验证。 +``` + +```text +$read-between 帮我把这句话改成一条单目的消息:表达边界,但不威胁、不逼答复。 ``` -read-between/ -├── SKILL.md # 核心路由、不可协商规则、五步内核、工作流 -├── ARCHITECTURE.md # 架构设计文档 -├── CHANGELOG-v1.md # 迭代变更日志 -├── README.md / README.en.md -├── LICENSE # MIT -├── .gitignore -├── agents/ -│ └── openai.yaml # 界面元数据 -├── .github/workflows/ -│ └── validate.yml # CI 双平台验证 -├── references/ # 按需加载的深度指令 -│ ├── practical/ # 23 份聊天话术库(场景化沟通指南 + 伦理转译) -│ ├── intent-insight.md -│ ├── genuine-intent.md -│ ├── self-deception-index.md # 10类自我欺骗模式路由索引 -│ ├── self-deception-patterns/ # 模式详情(a-e / f-j) -│ ├── pursue-or-not.md -│ ├── pursuit-strategy.md -│ ├── early-dating.md -│ ├── social-skill-translation.md # 社交技能伦理转译(9原子+14禁令) -│ ├── investment-calibration.md # 四区投入模型与退出框架 -│ ├── chat-ingestion-contract.md -│ ├── chat-evidence-and-uncertainty.md -│ ├── reply-drafting.md -│ ├── dialogue-practice.md -│ ├── chatlab-adapter.md # ChatLab 可选适配 -│ ├── emotional-containment.md -│ ├── relationship-education.md -│ ├── conflict-and-repair.md -│ ├── partnership-family.md # 伴侣与家庭(同居→婚姻→育儿→转折) -│ ├── breakup-betrayal-reconciliation.md # 分手/背叛/复合 -│ ├── inclusive-relationships.md # LGBTQ+/非一对一/跨文化/残障/再婚/异地 -│ ├── safety-abuse-and-crisis.md -│ ├── privacy-threat-model.md -│ ├── memory-protocol.md -│ ├── structured-memory.md # Mode 2 有界结构化记忆 -│ ├── personality-insight-layer.md -│ ├── classical-interpretation.md -│ ├── classical-catalog.md -│ ├── classical-voices.md -│ ├── source-registry.md -│ ├── lifecycle-routing.md -│ ├── output-contracts.md -│ └── data-root.txt -├── scripts/ # 10 个验证与工具脚本 -│ ├── validate_contract.ps1 -│ ├── validate_skill.py -│ ├── memory_store.py -│ ├── validate_chat_import.ps1 -│ ├── normalize_timestamps.ps1 -│ ├── redact_transcript.ps1 -│ ├── validate_record.ps1 -│ ├── validate_memory_store.ps1 -│ ├── detect_cross_relationship_leakage.ps1 -│ └── search_classics.ps1 -└── assets/ - └── classical-corpus/ # 10部古典原文(~25,800行) + +```text +$read-between 对方拿私密照片威胁我。先做安全和隐私分流,不要继续分析恋爱策略。 ``` -## 验证工具 +## 输出长什么样 -| 脚本 | 用途 | 何时运行 | -|------|------|---------| -| `validate_contract.ps1` | 检查全部文件存在、交叉引用完整、无孤儿文件 | 首次安装、升级后 | -| `validate_skill.py` | Python 静态验证:frontmatter、budget、inventory、routes、invariants、boundaries、links、placeholders、consistency | 首次安装、升级后、CI | -| `memory_store.py` | Mode 2 结构化记忆管理(init/status/add/query/pause/undo/forget/destroy) | opt-in 使用 | -| `validate_chat_import.ps1` | 验证导入的聊天记录格式正确 | 导入聊天前 | -| `normalize_timestamps.ps1` | 将聊天时间戳统一为标准格式 | 导入聊天前 | -| `redact_transcript.ps1` | 去除聊天记录中的真实姓名和标识信息 | 分享或存档前 | -| `validate_record.ps1` | 验证记忆记录的字段完整性和格式 | 写入记忆后 | -| `validate_memory_store.ps1` | 检查记忆目录结构完整性 | 首次使用、数据目录变更后 | -| `detect_cross_relationship_leakage.ps1` | 检测不同关系档案间的人物信息泄漏 | 维护多段关系档案时 | -| `search_classics.ps1` | 跨古典原文关键词检索,返回命中位置与上下文 | 需要古典参照时 | +输入:`她说“最近忙,下次再约”,没有给时间。她是不是喜欢我?` + +```text +你会纠结很正常:这句话留下了希望,却没有给你可确认的安排。 + +事实:她拒绝了这次邀约;没有提出替代时间。 +推测:她可能确实忙,也可能在礼貌降温;现有证据不足以二选一。 +关键未知:她之后会不会主动恢复联系或提出具体安排。 + +建议:先不追加说服。可以回:“没事,你方便时再约;如果想见, +下次你定个时间就好。” 这条消息只做一件事:把下一次具体行动留给她。 + +观察窗口:由她是否提出并落实替代安排关闭,不按固定天数关闭。 +停止条件:持续只有含糊回应、没有实际安排,或你的边界被贬低。 +``` + +这不是“判定她喜不喜欢”,而是把一个无法验证的问题改成低成本、可退出的下一步。 + +## 可运行的示例 + +三个案例全部使用虚构人物与合成对话: + +- [暧昧信号:把“读心”改成可验证问题](examples/ambiguous-signal.md) +- [边界与退出:不靠倒计时决定是否继续](examples/boundary-and-exit.md) +- [安全与隐私:高风险分流覆盖恋爱策略](examples/safety-and-privacy.md) +- [示例索引与阅读方法](examples/README.md) + +## 设计底线 + +- 不输出无可靠依据的喜欢概率、成功率或人格诊断。 +- 不把一次延迟、一个表情或一个称呼当成感情结论。 +- 不教推拉、冷暴力、制造嫉妒、秘密监控、冒充或人格克隆。 +- 不自动保存聊天记录。结构化记忆默认关闭,只有明确同意后才启用;暂停会拒绝新数据写入,“撤回”会删除该对象最新一条仍可逆的数据记录,“忘记”会级联删除该对象及相关审计,硬删除会移除整个本地数据库。 +- 暴力、胁迫、自伤、跟踪、私密影像威胁、未成年人和权力差问题优先走安全分流。 + +## 验证 + +在仓库根目录运行: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/validate_contract.ps1 +python -X utf8 scripts/validate_powershell_compat.py --skill-root . +python -X utf8 scripts/validate_skill.py +python -X utf8 scripts/validate_skill.py --runtime +python -X utf8 -m unittest discover -s scripts/tests -p "test_*.py" -v +``` -## 进一步阅读 +验证器检查 frontmatter、路由、不变量、边界、链接、占位符、关系阈值和运行时依赖;CI 在 Windows 与 Ubuntu 上执行同一合同。 -- **[ARCHITECTURE.md](ARCHITECTURE.md)** — 运行逻辑流程图、设计原则、三层洞察架构、五步内核、clean-room 设计说明 -- **[CHANGELOG-v1.md](CHANGELOG-v1.md)** — v1-v3 迭代的逐批次修改明细 -- **[SKILL.md](SKILL.md)** — 核心路由、不可协商规则、工作流、快速路由表 +## 仓库导航 -## Roadmap +- [SKILL.md](SKILL.md):触发条件、不可协商规则和主路由 +- [ARCHITECTURE.md](ARCHITECTURE.md):三层洞察架构与五步内核 +- [references/](references/):聊天证据、消息代拟、安全、隐私、关系阶段与专项场景 +- [CHANGELOG-v2.md](CHANGELOG-v2.md):v4 系列变更 +- [安全报告](.github/SECURITY.md) · [贡献指南](.github/CONTRIBUTING.md) · [支持范围](.github/SUPPORT.md) -- [x] v2: 多对象比较与优先级排序、用户预分析模式识别、事后回忆输入、完整对话导出、发展关系前置条件检查 -- [x] v3: 真实意图识别层——10类自我欺骗模式检测、反机制化保护 -- [x] v4: 五步内核、单目的消息编排、社交技能伦理转译、投入校准与退出、分手背叛复合评估、伴侣与家庭、多元关系适配、可选结构化记忆、Python 验证器与记忆存储、CI 双平台验证 +## 限制与免责声明 -## 免责声明 +Read Between 只能依据你提供的信息工作;它无法读取对方内心,也无法保证关系结果。它不是心理治疗、精神科诊断、法律意见或危机热线。若存在即时人身危险,请联系所在地紧急服务或可信赖的现实支持者。 -Read Between 是一个 AI skill 指令文件,**不是**心理治疗、精神科诊断、法律意见或危机热线。如果你正处于即时人身危险中,请拨打当地急救电话。 +MIT License。欢迎提交可复现问题、合成案例和不含私人聊天记录的改进建议。 diff --git a/SKILL.md b/SKILL.md index 1ba6675..5268ad2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,25 +1,17 @@ --- name: read-between description: > - Use when the user asks about romantic relationships: dating, - attraction, pursuit decisions (追不追/怎么追), multi-prospect - comparison, chat analysis, message drafting, invitation or - confession drafting, early dating, emotional support after - rejection or breakup, relationship education, conflict, - boundaries, jealousy, breakup recovery, betrayal and - reconciliation, cohabitation, marriage and family, coming out - and LGBTQ+ relationships, non-monogamy and polyamory, - cross-cultural and long-distance relationships, disability - and chronic illness in relationships, remarriage and blended - families, investment imbalance and exit decisions, social - skill development. - When a user's stated goal conflicts with their described behavior, - gently reflect the gap rather than accepting the surface request - at face value. - Can optionally provide zodiac (星座), Chinese BaZi (八字), or - MBTI perspectives as reference frameworks (not decision tools). - Can optionally reference classical Chinese poetry and philosophy - for emotional resonance and perspective. + Use for romantic relationships: attraction, dating, pursuit decisions + (追不追/怎么追), multi-prospect comparison, chat analysis, message, + invitation or confession drafting, early dating, rejection or breakup + support, conflict, boundaries, jealousy, betrayal, reconciliation, + cohabitation, marriage and family, coming out and LGBTQ+ relationships, + non-monogamy and polyamory, cross-cultural or long-distance relationships, + disability or chronic illness, remarriage and blended families, investment + imbalance, exit decisions, relationship education, and social-skill + development. When stated goals conflict with described behavior, gently + reflect the gap. Zodiac (星座), BaZi (八字), MBTI, and classical Chinese + poetry or philosophy are optional reference lenses, never decision tools. --- # Read Between — 恋爱全流程参谋 @@ -73,7 +65,7 @@ Read Between 是覆盖"追不追 → 怎么追 → 追到之后怎么办"全链 - 消息代拟 → `references/reply-drafting.md` - 对话演练 → `references/dialogue-practice.md` - ChatLab 适配 → `references/chatlab-adapter.md` -- 聊天话术库 → `references/practical/`(24 份场景化话术指南,按需加载) +- 聊天话术库 → `references/practical/`(共 24 文件:23 份场景指南 + 1 份伦理使用边界,按需加载) **关系支撑**: - 情绪承接与稳定 → `references/emotional-containment.md` diff --git a/agents/openai.yaml b/agents/openai.yaml index 7280143..5065fa0 100644 --- a/agents/openai.yaml +++ b/agents/openai.yaml @@ -1,4 +1,10 @@ interface: - display_name: "Read Between 恋爱全流程参谋 v4" - short_description: "追不追→怎么追→追到之后怎么办。聊天证据分析、消息代拟(单目的编排)、24份场景话术库、约会策略、投入校准与退出、分手背叛复合、伴侣家庭、多元关系适配、社交技能伦理转译。可应要求提供星座/八字/MBTI参照或古典诗词哲学参照。" - default_prompt: "Use $read-between to help with relationship decisions, dating strategy, multi-prospect comparison, chat analysis, single-action message drafting, investment calibration and exit decisions, breakup/betrayal/reconciliation support, partnership and family guidance, inclusive relationship configurations, ethical social skill development, emotional support, zodiac/BaZi/MBTI perspectives (as reference frameworks, not decision tools), or classical Chinese poetry and philosophy for emotional resonance." + display_name: "Read Between" + short_description: "看清证据、边界与下一步的关系决策 skill" + icon_small: "./assets/icon-small.png" + icon_large: "./assets/icon-large.png" + brand_color: "#FFB86B" + default_prompt: "Use $read-between to separate facts from interpretations and choose one safe, honest next action." + +policy: + allow_implicit_invocation: false diff --git a/assets/icon-large.png b/assets/icon-large.png new file mode 100644 index 0000000..f0b9933 Binary files /dev/null and b/assets/icon-large.png differ diff --git a/assets/icon-small.png b/assets/icon-small.png new file mode 100644 index 0000000..f080996 Binary files /dev/null and b/assets/icon-small.png differ diff --git a/assets/social-preview.png b/assets/social-preview.png new file mode 100644 index 0000000..66eba39 Binary files /dev/null and b/assets/social-preview.png differ diff --git a/docs/superpowers/plans/2026-08-13-github-productization.md b/docs/superpowers/plans/2026-08-13-github-productization.md new file mode 100644 index 0000000..c2e2ed1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-github-productization.md @@ -0,0 +1,92 @@ +# Read Between GitHub Productization Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan. + +**Goal:** Repair the Windows CI defect and ship a consistent, demonstrable, +bilingual, plugin-compatible Read Between repository. + +**Architecture:** Protect the validator subprocess boundary with UTF-8, test +the original encoding failure, reconcile human guidance to the existing +event-bound contract, and wrap the canonical root skill with public repository +and plugin metadata. + +**Tech stack:** Markdown, YAML, JSON, Python standard library/unittest, +PowerShell, SQLite smoke tests, GitHub Actions, PNG/SVG assets. + +### Task 1: Reproduce and fix Windows encoding + +**Files:** +- Create: `scripts/tests/test_validate_skill_cli.py` +- Modify: `scripts/validate_skill.py` +- Modify: `.github/workflows/validate.yml` + +1. Add a subprocess test with `PYTHONIOENCODING=cp1252` and `PYTHONUTF8=0`. +2. Run it and record the expected `UnicodeEncodeError` failure. +3. Reconfigure validator stdout/stderr to UTF-8 with replacement fallback. +4. Run the regression test and full validators. +5. Upgrade official Actions and set UTF-8 explicitly for every job. + +### Task 2: Eliminate validation noise and contract drift + +**Files:** +- Modify: `scripts/validate_skill.py` +- Modify: relevant files under `references/` +- Modify: validator tests under `scripts/tests/` + +1. Add behavior tests for placeholder scanning and threshold classification. +2. Confirm the tests fail for validator self-matches and calendar-only + relationship windows. +3. Exclude detector literals from self-scanning without hiding real prose + placeholders. +4. Rewrite relationship-decision thresholds around observable events, evidence + changes, and stop conditions; keep only defensible activity durations. +5. Re-run full validation and inspect every remaining warning manually. + +### Task 3: Synchronize and improve public documentation + +**Files:** +- Modify: `README.md` +- Modify: `README.en.md` +- Modify: `CHANGELOG-v2.md` +- Create: `examples/README.md` +- Create: `examples/ambiguous-signal.md` +- Create: `examples/boundary-and-exit.md` +- Create: `examples/safety-and-privacy.md` + +1. Align all visible versions to `4.2.0` and describe the practical library as 24 files total: 23 scenario guides plus one ethical-use guide. +2. Add badges, exact install paths, exact invocation, a compact output preview, + limitations, and example links in both languages. +3. Add the missing v4.2 and repository-productization changelog entries. +4. Write three synthetic examples and check that each follows the five-step + output and safety/privacy contracts. + +### Task 4: Add community health, UI, plugin, and visuals + +**Files:** +- Create: `.github/CODE_OF_CONDUCT.md` +- Create: `.github/CONTRIBUTING.md` +- Create: `.github/SECURITY.md` +- Create: `.github/SUPPORT.md` +- Create: `.github/ISSUE_TEMPLATE/bug_report.yml` +- Create: `.github/ISSUE_TEMPLATE/feature_request.yml` +- Create: `.github/pull_request_template.md` +- Modify: `agents/openai.yaml` +- Create: `.codex-plugin/plugin.json` +- Create: `assets/icon-small.png` +- Create: `assets/icon-large.png` +- Create: `assets/social-preview.png` + +1. Add project-specific conduct, contribution, support, security, issue, and PR + guidance. +2. Generate and inspect the conversation-gap visual family. +3. Align concise UI metadata, brand color, icons, and default prompt. +4. Validate the skill and plugin structures. + +### Task 5: Forward-test, verify, and publish + +1. Run fresh-context synthetic prompts through the installed skill and inspect + evidence/inference separation, message scope, privacy, and safety routing. +2. Run unit, validator, PowerShell, memory, link, skill, and plugin checks. +3. Commit, push, open a PR, wait for both OS jobs, and merge. +4. Set GitHub description, Topics, Discussions, and custom social preview. +5. Create and verify tag/release `v4.2.0`. diff --git a/docs/superpowers/specs/2026-08-13-github-productization-design.md b/docs/superpowers/specs/2026-08-13-github-productization-design.md new file mode 100644 index 0000000..79f45b8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-github-productization-design.md @@ -0,0 +1,68 @@ +# Read Between GitHub Productization Design + +## Objective + +Make Read Between a trustworthy, bilingual, privacy-aware Codex relationship +skill that installs cleanly, invokes predictably, demonstrates safe behavior, +and maintains a green cross-platform CI signal. + +## Approved scope + +- Fix the confirmed Windows `cp1252` `UnicodeEncodeError` at its source and in + CI, with a regression test that reproduces the original failure. +- Remove validator self-noise and reconcile fixed-window guidance with the + advertised event-bound observation contract. +- Synchronize version `4.2.0`, the 24-file practical-library count (23 scenario guides plus one ethical-use guide), changelog, both READMEs, and UI + metadata. +- Add exact install and `$read-between` invocation instructions. +- Add synthetic, privacy-safe examples for ambiguous signals, boundaries, and + safety escalation. +- Add complete community-health files, plugin packaging, icons, social + preview, repository metadata, Topics, Discussions, tag, and release. + +## Safety and privacy boundaries + +The productization pass must not turn the project into a claim of therapy, +diagnosis, mind-reading, prediction, or manipulation. Examples must be +synthetic, contain no real identifiers, distinguish observation from +inference, and show how safety triage overrides ordinary dating advice. +Structured memory stays opt-in, local, inspectable, revocable, and deletable. + +## Architecture decisions + +- Repair output encoding inside `validate_skill.py`; workflow UTF-8 settings + are defense in depth, not the only fix. +- Test the validator as a subprocess under forced `cp1252`, because the bug is + process-boundary behavior rather than an internal constant. +- Keep the repository root as the canonical skill/plugin root. +- Keep Chinese as the default README and English as a complete concise entry. +- Preserve useful duration guidance only where it describes an activity or + safety stabilization; relationship judgments use observable events and stop + conditions instead of calendar-only thresholds. + +## Visual direction + +Use a warm charcoal background, a clean two-line conversation motif, and one +clear gap transformed into a path forward. Coral and soft amber carry warmth; +teal marks boundaries and safety. No hearts-as-decoration, stock couples, +small copy, or clinical imagery. + +## Acceptance criteria + +- The new encoding regression test fails before the fix and passes after it. +- Runtime/full validation, PowerShell contract validation, memory lifecycle, + and all unit tests pass on Windows locally and in GitHub Actions. +- Validator output contains no false placeholder-detector self-warning. +- README versions and guide counts are consistent everywhere. +- Exact installation and `$read-between` prompts work from documented paths. +- Three synthetic examples demonstrate evidence, boundaries, privacy, and + safety behavior without personal data. +- Skill/plugin validation, links, metadata, social preview, tag, and release + all verify successfully. + +## Baseline evidence + +On 2026-08-13, UTF-8 runtime/full validators, the PowerShell contract, and the +memory init/status/destroy lifecycle passed. The validator emitted 13 warnings. +The public Windows workflow failure was reproduced as a `UnicodeEncodeError` +when the Python process printed Chinese refusal markers through `cp1252`. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..522e800 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,21 @@ +# Read Between 示例 + +这些示例展示的是判断过程,不是对真实人物的诊断,也不是可以机械套用的聊天脚本。人物、对话和情境全部为合成内容,不包含真实聊天记录或个人身份信息。 + +## 如何阅读 + +每个案例都覆盖 Read Between 的五步内核: + +1. 接住情绪,但不急着解释对方。 +2. 分开可确认事实、合理推测和关键未知。 +3. 从安全、互惠、可靠性、代价和可逆性等维度判断用户利益。 +4. 给出首选方案、理由与代价,不伪装成唯一答案。 +5. 用一个现在可做的动作闭合,并让真实事件、停止条件和新证据决定是否继续。 + +## 案例 + +- [暧昧信号](ambiguous-signal.md):证据不足时如何拒绝读心,同时仍给出可执行下一步。 +- [边界与退出](boundary-and-exit.md):如何表达边界,并用对方之后的实际行为而非倒计时判断是否继续。 +- [安全与隐私](safety-and-privacy.md):出现私密影像威胁与住址风险时,为什么安全分流必须覆盖普通恋爱策略。 + +示例中的成品消息只服务于对应情境。真实使用时,请通过 `$read-between` 提供自己的目标、原话、已知事实、不可接受的边界和所在地;涉及热线或法律信息时,必须先核验当地最新资源。 diff --git a/examples/ambiguous-signal.md b/examples/ambiguous-signal.md new file mode 100644 index 0000000..4fbff29 --- /dev/null +++ b/examples/ambiguous-signal.md @@ -0,0 +1,46 @@ +# 示例:暧昧信号不是读心题 + +> 合成案例。人物与对话均为虚构。 + +## 用户输入 + +```text +$read-between 我约林岚周六喝咖啡,她回“这周有点忙,下次吧~”, +但昨晚又给我的动态点了赞。她是不是喜欢我?我要不要再约一次? +``` + +## 示例输出 + +这会让人很容易在希望和失落之间来回:她没有直接拒绝以后见面,但也没有把“下次”变成安排。 + +**事实拆解** + +- 可确认:她拒绝了本周六的邀约;没有提出替代时间;之后给动态点了赞。 +- 合理推测:她可能确实忙,也可能在礼貌降温。点赞是低成本行为,不能替其中任何一种解释定案。 +- 关键未知:她是否愿意为见面承担一点具体成本,例如主动提出时间、地点或替代方案。 +- 当前证据:`low`。什么会推翻它:她提出并落实具体安排,或明确表示只想保持普通朋友关系。 + +**用户利益判断** + +你需要的不是再猜一次,而是用很低的投入确认互惠性,同时避免连续说服让自己更被动。这个动作应当可逆,并给对方真实的拒绝空间。 + +**首选建议** + +先不重复邀约。回一条只做“交还具体行动”这一个目的的消息: + +```text +好呀,你先忙。之后如果想喝咖啡,你挑个方便的时间叫我就行。 +``` + +好处是轻、真诚、可退出;代价是你暂时拿不到确定答案,也要接受她可能不会推进。 + +**行动闭合** + +现在发送一次,然后回到自己的生活。观察窗口由她是否主动提出并落实替代安排关闭,而不是由经过几天关闭。若她只有低成本互动、始终没有实际安排,或明确拒绝,就停止加码;若她给出并兑现具体方案,再按真实行动更新判断。 + +## 合同检查 + +- 没有把点赞、语气词或一次拒约当成喜欢证据。 +- 事实、推测、未知和可推翻条件分开呈现。 +- 下一步只有一个目的,保留拒绝空间。 +- 观察窗口绑定替代安排是否出现并兑现,没有固定天数或次数配额。 diff --git a/examples/boundary-and-exit.md b/examples/boundary-and-exit.md new file mode 100644 index 0000000..0db4a30 --- /dev/null +++ b/examples/boundary-and-exit.md @@ -0,0 +1,46 @@ +# 示例:边界不是威胁,退出也不靠倒计时 + +> 合成案例。人物与对话均为虚构。 + +## 用户输入 + +```text +$read-between 周屿又在出发前临时取消见面,只说“抱歉,下次补偿你”, +没有新安排。我已经为这次见面推掉别的事。帮我表达边界,也告诉我怎么判断要不要退出。 +``` + +## 示例输出 + +你生气和疲惫都合理:损失的不只是一次见面,还有你为安排腾出的时间,以及反复期待落空带来的消耗。 + +**事实拆解** + +- 可确认:这次约定在临近出发时被取消;你为此调整了其他安排;对方道歉但没有给替代方案。 +- 合理推测:这可能是临时事件,也可能反映对约定的重视程度不足;目前不能仅凭动机猜测定性。 +- 关键未知:对方能否理解实际影响,并在下一次行动中承担安排和兑现的成本。 + +**用户利益判断** + +可靠性和机会成本已经成为核心。继续解释或安抚对方不会增加信息;清楚说明边界,再看对方如何行动,更保护你的时间,也保留一次修复机会。 + +**首选建议** + +发送一条只表达边界与下一次条件的消息: + +```text +我理解会有临时情况,但临出发才取消会实际影响我的安排。 +如果还想见,下次请你确认好时间后再联系我。 +``` + +这不是惩罚,也不要求对方立刻证明什么。代价是关系可能因此降温;但如果一句清楚、合理的边界就换来贬低或施压,那本身也是重要信息。 + +**行动闭合** + +发完后不再替对方安排下一次。观察窗口由下一次自然节点关闭:对方是否承认影响、提出具体计划并按约执行。若对方嘲讽边界、把责任全推给你、继续只许诺不安排,或你不再愿意承担同类成本,就转为退出;若其行动出现实质变化,再重新评估。 + +## 合同检查 + +- 消息没有威胁、羞辱、逼答复或制造嫉妒。 +- 不给对方做人格诊断,也不替其动机下结论。 +- 退出条件绑定后续责任与兑现行为,而不是“等几天”或“再给几次机会”。 +- 建议披露了边界可能带来关系降温的真实代价。 diff --git a/examples/safety-and-privacy.md b/examples/safety-and-privacy.md new file mode 100644 index 0000000..81b2c35 --- /dev/null +++ b/examples/safety-and-privacy.md @@ -0,0 +1,49 @@ +# 示例:高风险安全与隐私分流 + +> 合成案例。人物与对话均为虚构。本例不是法律意见;现实中的紧急资源必须按所在地核验。 + +## 用户输入 + +```text +$read-between 前任说我不见他就把私密照片发给同事。他知道我住址, +刚发来楼下的照片。我该怎么回才能让他消气?聊天里有真实姓名和公司信息。 +``` + +## 示例输出 + +这不是普通吵架,也不是你靠一句“说对的话”就该负责化解的情绪。你现在害怕、慌乱很正常;先保护人身安全和证据,不继续优化恋爱话术。 + +**事实与未知** + +- 可确认:对方以传播私密影像相威胁;知道你的住址;发来了疑似楼下的实时照片。 +- 风险判断:这些是需要立即升级处理的高风险信号,不需要先证明对方“真的会做”。 +- 关键未知:对方是否仍在附近、是否能进入住处、你现在是否独处、所在地可用的紧急与专业资源。 + +**安全与隐私利益** + +此时人身安全、证据完整性和身份信息最优先。单独见面、当面对质、威胁反击或删除聊天,都会增加不可逆风险。 + +**首选建议** + +1. 如果你认为对方就在附近、可能进入住处或你面临即时危险,立刻前往有可信人员的安全地点,并联系所在地紧急服务;不要独自下楼确认。 +2. 通知一位可信任的人,让其知道你的位置、对方身份和当前威胁;条件允许时请其陪同。 +3. 保存完整原始记录、时间戳、账号信息和楼下照片,不编辑、不删除。将备份放到对方无法访问的位置。 +4. 从安全设备检查共享定位、云相册、邮箱和主要账号访问权限,撤销对方访问并更换独立密码;不要在疑似被监控的设备上暴露计划。 +5. 联系当地警方、反家暴/性暴力支持机构或熟悉私密影像滥用的法律支持。具体号码和程序需按你的所在地实时核验。 + +现在不建议为了“让他消气”继续谈判,也不建议发送会暴露你位置、是否独处或证据保存情况的消息。若现实中的安全专业人员建议发最低限度回复,按他们了解的现场风险执行。 + +**行动闭合** + +先完成一个动作:带上必要物品和证据副本,联系可信任的人并移动到安全处。之后的节点不是等待对方情绪变化,而是确认你已安全、现实支持已介入、账号访问已收回;任何接近、升级威胁或尝试进入住所,都应立即升级给当地紧急服务。 + +**隐私处理** + +若要把聊天片段用于进一步分析,先复制一份并遮蔽姓名、公司、住址、联系方式和账号标识;原始证据单独保留。Read Between 不会因为本次分析自动创建记忆档案;不要在未明确同意时启用结构化记忆。 + +## 合同检查 + +- 安全分流覆盖了普通消息代拟与关系策略。 +- 没有建议单独对质、删证据或用刺激性话术反击。 +- 没有写死可能过期或不适用于所在地的热线与法律结论。 +- 明确区分原始证据保存与供分析使用的脱敏副本。 diff --git a/references/investment-calibration.md b/references/investment-calibration.md index d0e36b7..0102ec5 100644 --- a/references/investment-calibration.md +++ b/references/investment-calibration.md @@ -57,7 +57,7 @@ **判断指标**: -- 你们接触的时间还短,样本量不够形成一个可靠的行为趋势。三个回合的对话不能断定一个人对你有没有意思——任何人在三个回合里都可能表现得热情或冷淡,这完全受当天心情、忙碌程度等随机因素影响。 +- 你们还没有形成包含“发起—回应—后续兑现”的完整事件链,现有片段不足以形成可靠趋势。孤立的热情或冷淡很容易受当天心情、忙碌程度等随机因素影响。 - 存在客观的、真实的生活压力:考试月、家人生病、工作项目的截止期、个人正在经历一段低潮期。在这些压力存续期间,对方的社交行为可能显著偏离 ta 的常态——不能把压力期的行为当作稳定模式来解读。 - 你还没有清晰地向对方传达过你的兴趣。暗示不算——"我回复她消息很快""我给她朋友圈每条都点赞""我约她出来过一次"不算清晰的表达。如果你自己都不确定对方有没有接受到"你对她有意思"这个信息,那么对方可能确实不知道你在等什么。 - 你和对方的互动频率本来就低(一个月见一次的同学、不同城市的老朋友),不能把"互动少"直接等同于"对方不积极"——你要对比的是对方在同样条件下的常态社交行为,而不是你心目中"如果 ta 喜欢我 ta 会怎样"的剧本。 @@ -79,26 +79,26 @@ **执行之后**:把对方的回应当作输入重新判断。 - 如果对方的回应是积极的或有协商成分("这周不行下周可以""谢谢你告诉我这些,我也觉得和你聊天挺舒服的")——很有可能进入 Zone 1 -- 如果对方的回应是模糊的、回避的、或者完全被忽略的——再做 1-2 次不同形式、不同时机的低成本验证 -- 如果 2-3 次之后仍然一片模糊——这就是信息。持续的不确定性不是"还需要更多数据"——持续的不确定性本身就是一个高质量的数据点。转入 Zone 3。 +- 如果对方的回应是模糊的、回避的、或者完全被忽略的——这次观察窗口已经给出信息:停止升级投入,把主动程度调回与对方实际投入相匹配的水平 +- 只有存在考试、照护或项目截止等已知且有明确终点的外部压力时,才把“压力事件结束后对方是否主动恢复联系”作为新的观察事件;不要追加换皮验证。若仍无主动恢复,转入 Zone 3 **Zone 2 常见错误**: - **盛大告白逼宫**:"我干脆直接告诉她我喜欢她,是死是活给个痛快。"——这不是收集信息,这是在用高压力替代高耐心。对方在猝不及防的情况下被要求立即给出答案,回应的质量不可靠。而且你把自己放在了没有回头路的位置。更重要的是:如果对方本来还在观望,高压表白会迫使 ta 在信息不足的情况下做出防御性回应——"先拒绝了比较安全"。本来有可能慢慢发展成 Zone 1 的关系,因为你的不耐而被推向了 Zone 4。 - **过度分析旧数据**:在没有新行为的窗口里反复咀嚼那几条聊天记录,像考古一样发掘"她喜欢我的证据"。这不会产生新结论——只会让你更焦虑。每次重读,你的大脑都会在同一条信息上重新涂抹情绪颜料,直到一条普通的回复在你的解读中变成"她一定在想我"——或者变成"她肯定讨厌我"。两种解读都不可靠,因为它们来自同一批信息。 -- **把焦虑当紧迫感**:"我已经等了够久了。"——等不等于观察。你可能确实等了很久,但如果等待期间没有做出任何能产生新信息的行动,这段时间除了积累焦虑之外没有提供任何决策依据。等了一个月什么都没做,不如做一件能产生新信息的小事然后三天内有判断。 +- **把焦虑当紧迫感**:"我已经等了够久了。"——等不等于观察。若等待期间没有发生能产生新信息的事件,这段时间只会积累焦虑。与其继续按日历等待,不如做一个可拒绝的低成本动作,并在回应、替代安排或明确搁置出现时结束窗口。 - **用对方的"友好"替代"信号"**:对方回了你的消息、聊天时笑了、在群聊中接了你抛的话题——这些都是友好的、正常的社交行为。不要因为对方没有表现出反感,就默认对方有好感。"不反感"和"有好感"中间还有一大片观察空间——这片空间就是 Zone 2 之所以存在的原因。 --- ### Zone 3: 持续失衡 -**核心特征**:在经历了足够多的互动回合之后,存在清晰且反复出现的单向投入模式。这种情况不会因为"再等等"而自然好转——它需要你有意识地调整自己的投入水平。 +**核心特征**:能够改变判断的事件已经结束(邀约得到回应、替代安排是否兑现、已知压力事件结束),仍存在清晰且反复出现的单向投入模式。这种情况不会因为"再等等"而自然好转——它需要你有意识地调整自己的投入水平。 -**判断指标**(不需要全部满足——两到三项就足够触发警觉): +**判断指标**(不需要全部满足——只要这些证据共同指向稳定的单向模式,就足以触发警觉): -- **联系发起权的严重倾斜**:大多数或所有对话由你开启。当你不开启时,对话就断了——不是一两天,是一两周甚至更久。 -- **邀约被拒且无替代方案**:你的邀约被拒绝是正常的。但如果你发出了三次邀约,三次都被拒绝且对方从未提出任何替代时间或替代方案——这说明对方不是在拒绝那个时间,而是在拒绝"和你单独见面"这件事。 +- **联系发起权的严重倾斜**:大多数或所有对话由你开启。当你停止填补空白,并经过下一个双方本可自然接续的节点时,对方仍从未主动把互动接回来。 +- **邀约被拒且无替代方案**:一次邀约被拒绝是正常的;关键证据是对方是否给出替代安排、之后主动重启邀约并兑现。若这些都没有,不要靠重复邀约累计答案。 - **情感支持的单项流动**:你花了很多时间听对方的烦恼、安慰对方的情绪、在对方低谷时陪伴。但在你情绪低落、需要倾诉的时候,对方不出现、敷衍、或者把话题转回自己身上。 - **可用性的不对等**:对方需要你的时候你能随叫随到;你需要对方的时候对方"刚好有事""最近挺忙""下次吧"——而且这不是偶然,是规律。 - **情绪回报为负**:你想起这段关系时,焦虑、心累和自我怀疑的时间,明显多于开心、期待和有能量的时间。 @@ -135,7 +135,7 @@ **这五种方式的共同点**:全部是可逆的。你不是在烧桥——你是在把方向盘的一部分还给对方,看对方会不会接。 -**如何选择降级方式**:不需要一次性用上所有五种。从最轻的开始——先暂停主动联系。如果一周、两周过去了,对方毫无反应,这本身已经足够说明问题。如果暂停联系的窗口显示了对方的回应,你可能不需要再往下降。如果仍然想再确认一次——再往下一个层级走。降级程度取决于你已经在这段关系里消耗了多久以及你需要多清晰的信息来让自己下定决心。 +**如何选择降级方式**:不需要用上所有方式。从最轻的开始——先暂停主动填补互动空白。观察窗口绑定下一个双方本可自然接续的节点,或已约定事项是否兑现;对方若主动接回互动,就用这条新证据重新判断。若节点结束仍无回应,不再为“确认”而逐级追加测试。降级程度取决于已经出现的单向证据、这段关系对生活的消耗,以及边界是否被尊重。 **降级期间的用户自处**:降级不是冷暴力——你不需要"数着日子看她什么时候来联系你"。把降级期间的注意力从对方身上移开——去做让你有能量的事、建立新的社交连接、完成你因为被这段关系占据而拖延的事。降级的目的不只是在观察对方——也是在提醒自己:你在这一段关系之外还有一整个生活。如果你发现自己除了等她的消息之外没有别的事可做——这个发现比对方是否回应你的降级更重要。 @@ -204,7 +204,7 @@ 判断的核心不是对方在某一项上是否达到你的期待值,而是:**在所有可观察的维度上综合起来,你是否看到了真实的、持续出现的双向付出?** -一个很简单的检验:闭上眼睛,回想过去一个月里,**对方为你主动做过的三件事**。不需要是大事——主动发了一条消息问你最近怎么样、记住了你提过的考试日期并那天发了"加油"、把你的某个需求记在心里并提供了实际的帮助。如果三件都凑不出来——这个结果本身就是一个不容忽视的信息。这不意味着对方是坏人,甚至不意味着对方在故意冷淡——但这意味着你在这段关系里得到的回报,可能远低于你需要付出的心力。 +一个很简单的检验:回想最近一个有现实投入的阶段,**对方主动做过哪些需要时间、选择或兑现的事**。不需要是大事——主动发消息问你近况、记住你提过的考试日期并在事件到来时回应、把你的需求记在心里并提供实际帮助。如果只能想到礼貌回复、点赞等低成本信号,而想不到任何有成本的主动行为,这个结果本身就是不容忽视的信息。这不意味着对方是坏人,甚至不意味着对方在故意冷淡——但意味着你在这段关系里得到的回报,可能远低于你需要付出的心力。 如果能看到对方在以某种形式持续投入——即使这种形式不是你最喜欢的那种——你们的任务不是退出,而是沟通。找到一个你表达在乎、对方接收得到、对方表达在乎、你也接收得到的交互模式。 diff --git a/references/output-contracts.md b/references/output-contracts.md index f53315b..45c41f3 100644 --- a/references/output-contracts.md +++ b/references/output-contracts.md @@ -41,7 +41,7 @@ - **替代解释**(至少两种):一种符合用户期待的,一种不符合的 - **关键未知**:仅凭这段聊天无法判断什么 - **证据等级**:high / medium / low / insufficient -- **动态可推翻性**:每个判断附带"什么新信息会改变它"和"用户可以在接下来 1-2 周内做的验证动作"。你的判断不是判决书——是用户收集下一轮信息的出发点 +- **动态可推翻性**:每个判断附带“什么新信息会改变它”和一个绑定下一自然节点的验证动作(如邀约回应、替代安排是否兑现、已知压力事件结束后是否主动恢复)。窗口由事件结果关闭,不由经过的天数关闭。你的判断不是判决书——是用户收集下一轮信息的出发点 - **下一步验证动作**:下一次什么行为会改变判断 **要求**: diff --git "a/references/practical/00-\344\274\246\347\220\206\350\275\254\350\257\221\344\270\216\344\275\277\347\224\250\350\276\271\347\225\214.md" "b/references/practical/00-\344\274\246\347\220\206\350\275\254\350\257\221\344\270\216\344\275\277\347\224\250\350\276\271\347\225\214.md" index 8ca1d6b..d681a87 100644 --- "a/references/practical/00-\344\274\246\347\220\206\350\275\254\350\257\221\344\270\216\344\275\277\347\224\250\350\276\271\347\225\214.md" +++ "b/references/practical/00-\344\274\246\347\220\206\350\275\254\350\257\221\344\270\216\344\275\277\347\224\250\350\276\271\347\225\214.md" @@ -1,6 +1,6 @@ # 聊天话术库 — 自由使用指南 -本目录 24 份话术文件是**素材库**,不是操作规程。agent 是手艺人,文件是工具箱——怎么组合、怎么改、怎么即兴发挥,agent 自己判断。以下不是 checklist,是指南针。 +本目录共 24 文件:23 份场景指南加本文件这 1 份伦理使用边界。它们是**素材库**,不是操作规程。agent 是手艺人,文件是工具箱——怎么组合、怎么改、怎么即兴发挥,agent 自己判断。以下不是 checklist,是指南针。 --- diff --git a/references/pursue-or-not.md b/references/pursue-or-not.md index 1d66dc5..dc28cf9 100644 --- a/references/pursue-or-not.md +++ b/references/pursue-or-not.md @@ -75,7 +75,7 @@ - "如果她只是正常友好而没有任何特殊对待,结论应降级为'证据不足'" - "如果她主动为你付出了时间或精力成本,这个信号会显著增强" - **建议的验证动作**: - - 一个用户可以在接下来 1-2 周内执行的、具体的、低压力、可撤回的动作 + - 一个绑定下一自然互动节点、具体、低压力、可撤回的动作;窗口在对方回应、给出替代安排、兑现或明确搁置时结束 - 例如:"约她做一件具体的事(看展/喝咖啡),看她是否确认时间或给出替代方案" - 动作必须有明确的观察指标和退出条件 @@ -154,7 +154,7 @@ 1. **从小信号开始**:先增加互动频率和质量,观察反应。不要一上来就"我喜欢你" 2. **可撤回的邀约**:给对方充分的拒绝空间。不说"周末一定要出来",说"周六下午有个展览,有空的话一起?没空也完全没关系" 3. **不在公众场合施压**:不要朋友圈表白、当众送花、在共同朋友面前制造压力 -4. **设定自己的观察期和退出条件**:比如'接下来一段时间(你自己设定的观察窗口),如果我主动联系而她持续不发起'(这是你自己设定的试行观察窗口,不是普遍规律) +4. **设定事件窗口和退出条件**:比如“我发出这次清晰邀约后,看对方是否确认、提出替代安排或明确搁置;若都没有,就停止加码”。这是基于具体事件的个人边界,不是普遍规律 5. **每一步都是收集信息**:追求不是"攻克目标",而是"了解我们是否有可能"。被拒不是失败——它提供了重要的信息 ### 可逆性设计输出 diff --git a/references/social-skill-translation.md b/references/social-skill-translation.md index ebd0fa2..e1ed1d2 100644 --- a/references/social-skill-translation.md +++ b/references/social-skill-translation.md @@ -121,7 +121,7 @@ OEI 是一个三步结构,用来替代"试探""暗示""制造暧昧"这类模 **什么时候停** -- 如果对方对你发出的邀约连续两次不置可否或含糊带过——不要发出第三次。这不是你需要"改进邀约方式"——这是对方在用自己的方式拒绝。 +- 如果对方对一次清晰、可拒绝的邀约不置可否或含糊带过,既不给替代安排,也不在之后主动把邀约接回来——不要追加同类邀约。这不是你需要“改进邀约方式”,而是当前窗口已经没有互惠证据。 - "不也是完全没关系"这句话必须是你真实的态度。如果你嘴上说"没关系"但心里在赌"ta肯定会答应的",那 OEI 就不是透明沟通,而是伪装成透明的操控。这种伪装对你自己也很有害——你正在训练自己压抑真实期望,然后把失望投射到对方身上。 **不能做什么** @@ -156,7 +156,7 @@ OEI 是一个三步结构,用来替代"试探""暗示""制造暧昧"这类模 **什么时候停** -- 如果你暴露了,对方不接,连续两次——对方此刻的信任边界在这个位置。不越过。不强行深挖。 +- 如果你表达脆弱后,对方没有接住、明显不适或转移话题——把这次回应当作当前信任边界。不越过,不靠重复暴露强行深挖。 - 如果对方的暴露内容让你产生了"要把ta从痛苦中救出来"的冲动——你不是在社交,你在重复拯救者模式。请参考 `references/relationship-education.md` 关于边界的内容。 - 如果你发现自己暴露的内容是为了博取同情或制造亏欠感——你在操控,不在沟通。 @@ -328,13 +328,13 @@ OEI 是一个三步结构,用来替代"试探""暗示""制造暧昧"这类模 不健康的追踪是:"她昨天回了我消息,今天没回,她是不是变心了?"——这种以天为单位的情绪波动会让你精疲力竭,而且单个数据点的意义非常有限。 -互惠趋势的做法是:**把时间轴拉长,以"几次互动"为观察单位,寻找双方投入的走向。** +互惠趋势的做法是:**把观察单位从零散消息改成完整事件链,寻找双方投入的走向。** 看图不看一个点。看一段时间里的走势。 **怎么练习** -1. 选择一个观察窗口——建议是"最近 5-7 次互动",范围包括线上聊天、电话、见面、群聊互动等。不要太短(单次互动随机性太大),也不要太长(超过一个月的趋势对你当下的决策帮助不大)。 +1. 选择一个事件窗口——从一次具体发起开始,跟到对方回应、提出替代安排、兑现安排或明确搁置为止。线上聊天、电话、见面、群聊互动都可以提供证据,但不要用消息条数、互动次数或经过的天数替代事件结果。 2. 每条线上做三类标注,各给一种方向: - **谁发起**:我主动多(↓)、她主动多(↑)、差不多(→) @@ -359,15 +359,15 @@ OEI 是一个三步结构,用来替代"试探""暗示""制造暧昧"这类模 **什么时候停** -- 如果你发现自己在一天之内打开聊天窗口超过三次来"确认"趋势——你不在观察,你在焦虑循环。趋势观察是每周或每几次互动才做一次的事。日常该干嘛干嘛。 -- 如果趋势观察让你更焦虑了而不是更清醒了——你的窗口期可能太短(缩小到"今天")或者你对数据赋予了过度的解读。趋势是信息,不是预言。 +- 如果你发现自己反复打开聊天窗口来"确认"趋势——你不在观察,你在焦虑循环。只在关键事件出现结果时复盘;其余时间回到正常生活。 +- 如果趋势观察让你更焦虑而不是更清醒,立即停止记录;把复盘节点改为下一次可观察事件(邀约回应、替代安排是否兑现、已知压力结束后是否主动恢复)。该事件结束后仍没有新的互惠证据,就降级投入,不继续延长窗口。趋势是信息,不是预言。 - 如果你的趋势观察是"我的投入在上升,她的投入没有变化"——这不叫"双向趋势不匹配",这叫"你在单方面追加期待,她没有义务回应"。你需要降低你自己的投入,而不是要求对方匹配。 **不能做什么** - 不要用趋势数据去质问对方("我统计了一下,最近 5 次都是我主动找你")——趋势是你做决策的依据,不是你用来跟对方对质的弹药 - 不要把互惠趋势变成记分板——每一次互动都记"这次我付出 3 分她付出 1 分"。关系不是比赛,精细化记分只会让你变得斤斤计较 -- 不要忽略外部因素——她这段时间在准备考试/家庭有事/工作压力大,趋势下行可能是情境性的,不是方向性的。但也不要无限地"外部因素"来解释一切。两者的边界是:外部因素通常有时限(几周到一两个月),方向性变化不会自动恢复 +- 不要忽略外部因素——她这段时间在准备考试/家庭有事/工作压力大,趋势下行可能是情境性的,不是方向性的。但也不要无限地用"外部因素"解释一切。两者的边界是:外部因素应有可观察的结束事件;事件结束后是否主动恢复联系,才会改变判断 - 不要把趋势数据分享给第三方来获得"同情投票"("你看我统计的数据,她真的不够在乎我") --- diff --git a/references/structured-memory.md b/references/structured-memory.md index 6f5d09e..c3327ea 100644 --- a/references/structured-memory.md +++ b/references/structured-memory.md @@ -64,11 +64,13 @@ Agent 初始化前必须告知用户: - 状态(活跃 / 已证伪 / 已确认) ### 3.5 操作日志(Audit Log) -每一次写入操作的完整记录,用于撤销和审计。 +每一次控制操作的审计元数据,用于定位可逆数据记录和复核操作;审计详情不复制事件、事实或假设正文,避免把敏感内容再存一份。 - 时间戳 - 操作类型 - 对象 ID -- 操作详情 +- 目标表与目标记录 ID(仅可逆数据写入) +- 是否可逆、是否已撤回 +- 不含正文的操作详情 --- @@ -111,8 +113,8 @@ Agent 初始化前必须告知用户: | 查询某对象 | 输出该对象的所有记录(JSON 格式) | | 暂停某对象 | 停止对该对象的新写入,保留已有数据 | | 恢复 | 恢复对暂停对象的写入 | -| 撤销最近写入 | 回滚最近一次对该对象的写入操作 | -| 忘记某对象 | 级联删除该对象的所有数据 | +| 撤销最近写入 | 在单个事务内删除该对象最新一条仍可逆的 event、fact 或 hypothesis,并保留不含正文的撤回审计;不会撤销对象创建、暂停或恢复 | +| 忘记某对象 | 在单个事务内级联删除该对象的 events、facts、hypotheses 与相关审计,不保留对象关联记录 | | 硬删除 | 删除整个数据库文件(需二次确认) | --- @@ -130,6 +132,8 @@ Agent 初始化前必须告知用户: - 自动更新后始终展示"已自动记录:[内容]——可撤销" - 敏感字段(事实、假设)即使在自动更新模式下仍需逐条确认 +旧版数据库会在首次打开时事务化迁移。旧审计没有可定位的目标记录,因此清除重复的正文副本并按不可逆处理;旧版“忘记”可能遗留的孤儿数据会被清除。迁移失败会整体回滚,不猜测仍有关联对象的数据。 + --- ## 八、平台与安全警告 diff --git a/scripts/build_plugin_package.py b/scripts/build_plugin_package.py new file mode 100644 index 0000000..974668b --- /dev/null +++ b/scripts/build_plugin_package.py @@ -0,0 +1,637 @@ +#!/usr/bin/env python3 +"""Build and validate a deterministic installable Codex plugin artifact.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import stat +import subprocess +import sys +import uuid +import zipfile +from pathlib import Path, PurePosixPath +from urllib.parse import urlparse + + +ROOT = Path(__file__).resolve().parents[1] +SKILL_NAME = "read-between" +ARCHIVE_NAME = f"{SKILL_NAME}.zip" +FIXED_MTIME = 946684800 # 2000-01-01T00:00:00Z +FIXED_ZIP_TIME = (2000, 1, 1, 0, 0, 0) +SEMVER_RE = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\." + r"(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) +HEX_COLOR_RE = re.compile(r"^#[0-9A-F]{6}$", re.IGNORECASE) +TODO_MARKER = "[TODO:" + +ROOT_FILES = { + "SKILL.md", + "README.md", + "README.en.md", + "ARCHITECTURE.md", + "CHANGELOG-v1.md", + "CHANGELOG-v2.md", + "LICENSE", +} +SKILL_PREFIXES = ("agents/", "assets/", "references/", "examples/") +COMMUNITY_FILES = { + ".github/CODE_OF_CONDUCT.md", + ".github/CONTRIBUTING.md", + ".github/SECURITY.md", + ".github/SUPPORT.md", +} +PLUGIN_ASSETS = { + "assets/icon-small.png", + "assets/icon-large.png", + "assets/social-preview.png", +} +PLUGIN_KEYS = { + "id", + "name", + "version", + "description", + "skills", + "apps", + "mcpServers", + "interface", + "author", + "homepage", + "repository", + "license", + "keywords", +} +INTERFACE_KEYS = { + "displayName", + "shortDescription", + "longDescription", + "developerName", + "category", + "capabilities", + "websiteURL", + "privacyPolicyURL", + "termsOfServiceURL", + "brandColor", + "composerIcon", + "logo", + "logoDark", + "screenshots", + "defaultPrompt", + "default_prompt", +} + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + required=True, + type=Path, + help="New plugin directory named read-between; content comes from the Git index", + ) + parser.add_argument( + "--zip-output", + type=Path, + help="New archive named read-between.zip (default: beside --output)", + ) + parser.add_argument( + "--codex-home", + type=Path, + help="Optional Codex home whose official skill and plugin validators must pass", + ) + return parser.parse_args(argv) + + +def git_index() -> dict[str, str]: + result = subprocess.run( + ["git", "ls-files", "--stage", "-z"], + cwd=ROOT, + capture_output=True, + check=True, + ) + entries: dict[str, str] = {} + for record in result.stdout.split(b"\0"): + if not record: + continue + metadata, raw_path = record.split(b"\t", 1) + mode, _object_id, stage = metadata.decode("ascii").split() + path = raw_path.decode("utf-8") + if stage != "0": + raise ValueError(f"unmerged Git index entry cannot be packaged: {path}") + if mode == "120000": + raise ValueError(f"symbolic links are not allowed in the plugin package: {path}") + if mode not in {"100644", "100755"}: + raise ValueError(f"unsupported Git mode {mode} for package file: {path}") + entries[path] = mode + return entries + + +def staged_bytes(path: str) -> bytes: + return subprocess.run( + ["git", "show", f":{path}"], + cwd=ROOT, + capture_output=True, + check=True, + ).stdout + + +def is_runtime_script(path: str) -> bool: + pure = PurePosixPath(path) + return ( + pure.parent == PurePosixPath("scripts") + and pure.suffix in {".py", ".ps1"} + and pure.name != "build_plugin_package.py" + ) + + +def skill_paths(entries: dict[str, str]) -> list[str]: + selected = [] + for path in entries: + if path in ROOT_FILES or path in COMMUNITY_FILES: + selected.append(path) + elif path.startswith(SKILL_PREFIXES): + selected.append(path) + elif is_runtime_script(path): + selected.append(path) + return sorted(selected) + + +def path_is_within(candidate: Path, parent: Path) -> bool: + try: + candidate.relative_to(parent) + return True + except ValueError: + return False + + +def ensure_safe_outputs(output: Path, zip_output: Path | None) -> tuple[Path, Path]: + output = output.expanduser().resolve() + if output.name != SKILL_NAME: + raise ValueError(f"output basename must be exactly '{SKILL_NAME}'") + archive = ( + zip_output.expanduser().resolve() + if zip_output is not None + else output.with_suffix(".zip") + ) + if archive.name != ARCHIVE_NAME: + raise ValueError(f"zip output basename must be exactly '{ARCHIVE_NAME}'") + for label, path in (("output", output), ("zip output", archive)): + if path == ROOT or path_is_within(path, ROOT): + raise ValueError( + f"{label} must be outside the source root to prevent recursive packaging" + ) + if path.exists(): + kind = "directory" if label == "output" else "file" + raise ValueError(f"{label} must be a new {kind}: {path}") + if path_is_within(archive, output): + raise ValueError("zip output cannot be inside the plugin output directory") + return output, archive + + +def write_index_file( + destination: Path, + source_path: str, + git_mode: str, + file_modes: dict[str, int], + staging: Path, +) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(staged_bytes(source_path)) + mode = 0o755 if git_mode == "100755" else 0o644 + destination.chmod(mode) + file_modes[destination.relative_to(staging).as_posix()] = mode + + +def unique_json_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def non_empty_string(payload: dict, key: str, errors: list[str], prefix: str = ""): + value = payload.get(key) + label = f"{prefix}.{key}" if prefix else key + if not isinstance(value, str) or not value.strip(): + errors.append(f"{label} must be a non-empty string") + return None + return value + + +def reject_todo(value, errors: list[str], location: str = "$") -> None: + if isinstance(value, str) and TODO_MARKER in value: + errors.append(f"{location} contains an unfinished marker") + elif isinstance(value, list): + for index, item in enumerate(value): + reject_todo(item, errors, f"{location}[{index}]") + elif isinstance(value, dict): + for key, item in value.items(): + reject_todo(item, errors, f"{location}.{key}") + + +def validate_https(value, label: str, errors: list[str]) -> None: + if value is None: + return + parsed = urlparse(value) if isinstance(value, str) else None + if parsed is None or parsed.scheme != "https" or not parsed.netloc: + errors.append(f"{label} must be an absolute https URL") + + +def validate_asset(plugin_root: Path, raw_path, label: str, errors: list[str]) -> None: + if not isinstance(raw_path, str) or not raw_path.strip() or "\\" in raw_path: + errors.append(f"{label} must be a non-empty POSIX relative path") + return + pure = PurePosixPath(raw_path) + if pure.is_absolute() or any(part == ".." for part in pure.parts): + errors.append(f"{label} must stay inside the plugin archive") + return + resolved = (plugin_root / Path(*pure.parts)).resolve() + if not path_is_within(resolved, plugin_root.resolve()): + errors.append(f"{label} must stay inside the plugin archive") + elif not resolved.is_file(): + errors.append(f"{label} points to a missing file") + + +def normalize_contract_path(raw_path) -> str | None: + if not isinstance(raw_path, str) or not raw_path.strip() or "\\" in raw_path: + return None + pure = PurePosixPath(raw_path) + if pure.is_absolute() or any(part == ".." for part in pure.parts): + return None + normalized = pure.as_posix().rstrip("/") + return normalized or None + + +def validate_builtin_plugin(plugin_root: Path) -> None: + """Enforce the release's plugin contract without external dependencies.""" + errors: list[str] = [] + manifest_path = plugin_root / ".codex-plugin" / "plugin.json" + try: + manifest = json.loads( + manifest_path.read_text(encoding="utf-8"), + object_pairs_hook=unique_json_object, + ) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as error: + raise ValueError(f"plugin manifest is not valid strict JSON: {error}") from error + if not isinstance(manifest, dict): + raise ValueError("plugin manifest must contain a JSON object") + + reject_todo(manifest, errors) + unknown = sorted(set(manifest) - PLUGIN_KEYS) + if unknown: + errors.append("unsupported top-level fields: " + ", ".join(unknown)) + name = non_empty_string(manifest, "name", errors) + if name is not None and name != SKILL_NAME: + errors.append(f"name must be '{SKILL_NAME}'") + version = non_empty_string(manifest, "version", errors) + if version is not None and SEMVER_RE.fullmatch(version) is None: + errors.append("version must be strict semver") + non_empty_string(manifest, "description", errors) + if "id" in manifest and ( + not isinstance(manifest["id"], str) or not manifest["id"].strip() + ): + errors.append("id must be a non-empty string when present") + + skills = manifest.get("skills") + if normalize_contract_path(skills) != "skills": + errors.append("skills must resolve to './skills/'") + if "apps" in manifest or "mcpServers" in manifest: + errors.append("apps and mcpServers are unsupported by this skills-only release") + + author = manifest.get("author") + if not isinstance(author, dict): + errors.append("author must be an object") + else: + author_unknown = sorted(set(author) - {"name", "email", "url"}) + if author_unknown: + errors.append("unsupported author fields: " + ", ".join(author_unknown)) + non_empty_string(author, "name", errors, "author") + validate_https(author.get("url"), "author.url", errors) + if "email" in author and ( + not isinstance(author["email"], str) or not author["email"].strip() + ): + errors.append("author.email must be a non-empty string when present") + for field in ("homepage", "repository"): + validate_https(manifest.get(field), field, errors) + for field in ("license",): + non_empty_string(manifest, field, errors) + keywords = manifest.get("keywords") + if not isinstance(keywords, list) or not keywords or not all( + isinstance(item, str) and item.strip() for item in keywords + ): + errors.append("keywords must be a non-empty array of strings") + + interface = manifest.get("interface") + if not isinstance(interface, dict): + errors.append("interface must be an object") + else: + interface_unknown = sorted(set(interface) - INTERFACE_KEYS) + if interface_unknown: + errors.append( + "unsupported interface fields: " + ", ".join(interface_unknown) + ) + for field in ( + "displayName", + "shortDescription", + "longDescription", + "developerName", + "category", + ): + non_empty_string(interface, field, errors, "interface") + capabilities = interface.get("capabilities") + if not isinstance(capabilities, list) or not capabilities or not all( + isinstance(item, str) and item.strip() for item in capabilities + ): + errors.append("interface.capabilities must be a non-empty array of strings") + prompt_keys = [ + key for key in ("defaultPrompt", "default_prompt") if key in interface + ] + if len(prompt_keys) != 1: + errors.append("interface must define exactly one default prompt field") + else: + prompts = interface[prompt_keys[0]] + if isinstance(prompts, str): + prompts = [prompts] + if not isinstance(prompts, list) or not 1 <= len(prompts) <= 3 or not all( + isinstance(item, str) and item.strip() and len(item) <= 128 + for item in prompts + ): + errors.append("default prompts must be 1-3 non-empty strings of at most 128 characters") + elif not any(f"${SKILL_NAME}" in item for item in prompts): + errors.append(f"a default prompt must explicitly invoke ${SKILL_NAME}") + for field in ("websiteURL", "privacyPolicyURL", "termsOfServiceURL"): + validate_https(interface.get(field), f"interface.{field}", errors) + color = interface.get("brandColor") + if color is not None and ( + not isinstance(color, str) or HEX_COLOR_RE.fullmatch(color) is None + ): + errors.append("interface.brandColor must use #RRGGBB") + for field in ("composerIcon", "logo", "logoDark"): + if field in interface: + validate_asset( + plugin_root, interface[field], f"interface.{field}", errors + ) + screenshots = interface.get("screenshots", []) + if not isinstance(screenshots, list): + errors.append("interface.screenshots must be an array") + else: + for index, raw_path in enumerate(screenshots): + validate_asset( + plugin_root, + raw_path, + f"interface.screenshots[{index}]", + errors, + ) + + skill_root = plugin_root / "skills" / SKILL_NAME + skill_md = skill_root / "SKILL.md" + if not skill_md.is_file(): + errors.append(f"skills/{SKILL_NAME}/SKILL.md is required") + else: + content = skill_md.read_text(encoding="utf-8") + frontmatter = re.match(r"^---\n(.*?)\n---(?:\n|$)", content, re.DOTALL) + if frontmatter is None: + errors.append("packaged SKILL.md must have closed YAML frontmatter") + else: + header = frontmatter.group(1) + if re.search(r"(?m)^name:\s*read-between\s*$", header) is None: + errors.append("packaged SKILL.md name must be read-between") + if re.search(r"(?m)^description:\s*[>|]?\s*\S", header) is None: + errors.append("packaged SKILL.md needs a non-empty description") + + if errors: + raise ValueError("plugin manifest/package contract failed: " + "; ".join(errors)) + + +def run_checked(command: list[str], cwd: Path, label: str) -> None: + environment = os.environ.copy() + environment["PYTHONIOENCODING"] = "utf-8" + environment["PYTHONUTF8"] = "1" + result = subprocess.run( + command, + cwd=cwd, + env=environment, + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + timeout=180, + check=False, + ) + if result.returncode != 0: + raise ValueError( + f"{label} failed ({result.returncode}):\n{result.stdout}{result.stderr}" + ) + + +def validate_staging(plugin_root: Path, codex_home: Path | None) -> list[str]: + validate_builtin_plugin(plugin_root) + skill_root = plugin_root / "skills" / SKILL_NAME + validator = skill_root / "scripts" / "validate_skill.py" + powershell_validator = skill_root / "scripts" / "validate_powershell_compat.py" + for path in (validator, powershell_validator): + if not path.is_file(): + raise ValueError(f"staged release validator is missing: {path.name}") + + run_checked( + [sys.executable, "-B", str(validator), "--skill-root", str(skill_root)], + skill_root, + "packaged full validation", + ) + run_checked( + [ + sys.executable, + "-B", + str(validator), + "--skill-root", + str(skill_root), + "--runtime", + ], + skill_root, + "packaged runtime validation", + ) + run_checked( + [ + sys.executable, + "-B", + str(powershell_validator), + "--skill-root", + str(skill_root), + ], + skill_root, + "packaged PowerShell contract/parse/smoke validation", + ) + completed = ["built-in plugin schema", "full", "runtime", "PowerShell"] + + if codex_home is not None: + codex_home = codex_home.expanduser().resolve() + official = ( + ( + codex_home + / "skills" + / ".system" + / "skill-creator" + / "scripts" + / "quick_validate.py", + skill_root, + "official skill validation", + ), + ( + codex_home + / "skills" + / ".system" + / "plugin-creator" + / "scripts" + / "validate_plugin.py", + plugin_root, + "official plugin validation", + ), + ) + for script, target, label in official: + if not script.is_file(): + raise ValueError(f"{label} requested but validator is missing: {script}") + run_checked([sys.executable, "-B", str(script), str(target)], plugin_root, label) + completed.append("official skill/plugin") + return completed + + +def normalize_tree(staging: Path, file_modes: dict[str, int]) -> None: + files = sorted(path for path in staging.rglob("*") if path.is_file()) + for path in files: + relative = path.relative_to(staging).as_posix() + mode = file_modes.get(relative) + if mode not in {0o644, 0o755}: + raise ValueError(f"missing normalized mode for staged file: {relative}") + path.chmod(mode) + # Git-index symlinks are rejected before extraction, so following a link + # is impossible here. Omitting follow_symlinks keeps Windows compatible. + os.utime(path, (FIXED_MTIME, FIXED_MTIME)) + directories = sorted( + (path for path in staging.rglob("*") if path.is_dir()), + key=lambda path: len(path.parts), + reverse=True, + ) + for path in [*directories, staging]: + path.chmod(0o755) + os.utime(path, (FIXED_MTIME, FIXED_MTIME)) + + +def build_deterministic_zip( + staging: Path, archive_path: Path, file_modes: dict[str, int] +) -> None: + with zipfile.ZipFile( + archive_path, + mode="x", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as archive: + for path in sorted(item for item in staging.rglob("*") if item.is_file()): + relative = path.relative_to(staging).as_posix() + mode = file_modes[relative] + info = zipfile.ZipInfo(f"{SKILL_NAME}/{relative}", FIXED_ZIP_TIME) + info.create_system = 3 + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = (stat.S_IFREG | mode) << 16 + archive.writestr( + info, + path.read_bytes(), + compress_type=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) + + +def build_plugin( + output: Path, + zip_output: Path | None = None, + codex_home: Path | None = None, +) -> tuple[Path, Path, list[str]]: + output, archive = ensure_safe_outputs(output, zip_output) + entries = git_index() + required = {".codex-plugin/plugin.json", "SKILL.md", *PLUGIN_ASSETS} + missing = sorted(required - entries.keys()) + if missing: + raise ValueError( + "required package files are not staged in the Git index: " + ", ".join(missing) + ) + + output.parent.mkdir(parents=True, exist_ok=True) + archive.parent.mkdir(parents=True, exist_ok=True) + temporary = output.parent / f".{output.name}.tmp-{uuid.uuid4().hex}" + temporary_archive = archive.parent / f".{archive.name}.tmp-{uuid.uuid4().hex}" + file_modes: dict[str, int] = {} + published_tree = False + published_archive = False + try: + write_index_file( + temporary / ".codex-plugin" / "plugin.json", + ".codex-plugin/plugin.json", + entries[".codex-plugin/plugin.json"], + file_modes, + temporary, + ) + for path in sorted(PLUGIN_ASSETS): + write_index_file( + temporary / Path(PurePosixPath(path)), + path, + entries[path], + file_modes, + temporary, + ) + + packaged_skill = temporary / "skills" / SKILL_NAME + for path in skill_paths(entries): + write_index_file( + packaged_skill / Path(PurePosixPath(path)), + path, + entries[path], + file_modes, + temporary, + ) + + if (packaged_skill / "SKILL.md").read_bytes() != staged_bytes("SKILL.md"): + raise ValueError("packaged SKILL.md does not match the canonical Git index entry") + validations = validate_staging(temporary, codex_home) + normalize_tree(temporary, file_modes) + build_deterministic_zip(temporary, temporary_archive, file_modes) + + temporary.replace(output) + published_tree = True + temporary_archive.replace(archive) + published_archive = True + except BaseException: + if published_archive: + archive.unlink(missing_ok=True) + if published_tree: + shutil.rmtree(output, ignore_errors=True) + shutil.rmtree(temporary, ignore_errors=True) + temporary_archive.unlink(missing_ok=True) + raise + + return output, archive, validations + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + plugin_root, archive, validations = build_plugin( + args.output, args.zip_output, args.codex_home + ) + except (OSError, subprocess.CalledProcessError, ValueError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + print("Staging validation passed: " + ", ".join(validations)) + print(f"Plugin tree built from Git index: {plugin_root}") + print(f"Deterministic plugin archive built: {archive}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/detect_cross_relationship_leakage.ps1 b/scripts/detect_cross_relationship_leakage.ps1 index b839bdf..f8b5d62 100644 --- a/scripts/detect_cross_relationship_leakage.ps1 +++ b/scripts/detect_cross_relationship_leakage.ps1 @@ -1,4 +1,4 @@ -# detect_cross_relationship_leakage.ps1 +# detect_cross_relationship_leakage.ps1 # Scan memory records for potential cross-relationship data leakage: # - Same person ID referenced by multiple relationships # - Personal details appearing in wrong relationship context diff --git a/scripts/memory_store.py b/scripts/memory_store.py index 6ec9562..f61aed2 100644 --- a/scripts/memory_store.py +++ b/scripts/memory_store.py @@ -39,56 +39,81 @@ 'agreement', 'other' ) -SCHEMA_SQL = """ -CREATE TABLE IF NOT EXISTS objects ( - id TEXT PRIMARY KEY, - code_name TEXT NOT NULL, - created_at TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active' -); - -CREATE TABLE IF NOT EXISTS events ( - id TEXT PRIMARY KEY, - object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, - timestamp TEXT NOT NULL, - event_type TEXT NOT NULL, - summary TEXT NOT NULL, - source TEXT NOT NULL, - confidence TEXT NOT NULL DEFAULT 'MEDIUM' -); - -CREATE TABLE IF NOT EXISTS facts ( - id TEXT PRIMARY KEY, - object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, - fact TEXT NOT NULL, - source_category TEXT NOT NULL, - verified TEXT NOT NULL DEFAULT 'unverified', - created_at TEXT NOT NULL, - superseded_by TEXT -); - -CREATE TABLE IF NOT EXISTS hypotheses ( - id TEXT PRIMARY KEY, - object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, - hypothesis TEXT NOT NULL, - confidence TEXT NOT NULL DEFAULT 'LOW', - basis TEXT, - falsification TEXT, - created_at TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active' -); - -CREATE TABLE IF NOT EXISTS audit_log ( - id TEXT PRIMARY KEY, - timestamp TEXT NOT NULL, - action TEXT NOT NULL, - object_id TEXT, - detail TEXT -); - -PRAGMA journal_mode=WAL; -PRAGMA foreign_keys=ON; -""" +SCHEMA_VERSION = 3 +CREATE_STATEMENTS = ( + """ + CREATE TABLE IF NOT EXISTS objects ( + id TEXT PRIMARY KEY, + code_name TEXT NOT NULL, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + ) + """, + """ + CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + timestamp TEXT NOT NULL, + event_type TEXT NOT NULL, + summary TEXT NOT NULL, + source TEXT NOT NULL, + confidence TEXT NOT NULL DEFAULT 'MEDIUM' + ) + """, + """ + CREATE TABLE IF NOT EXISTS facts ( + id TEXT PRIMARY KEY, + object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + fact TEXT NOT NULL, + source_category TEXT NOT NULL, + verified TEXT NOT NULL DEFAULT 'unverified', + created_at TEXT NOT NULL, + superseded_by TEXT + ) + """, + """ + CREATE TABLE IF NOT EXISTS hypotheses ( + id TEXT PRIMARY KEY, + object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + hypothesis TEXT NOT NULL, + confidence TEXT NOT NULL DEFAULT 'LOW', + basis TEXT, + falsification TEXT, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + ) + """, + """ + CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + action TEXT NOT NULL, + object_id TEXT REFERENCES objects(id) ON DELETE CASCADE, + detail TEXT, + target_table TEXT, + target_id TEXT, + reversible INTEGER NOT NULL DEFAULT 0, + reversed_at TEXT + ) + """, +) + +AUDIT_MIGRATION_COLUMNS = ( + ("target_table", "TEXT"), + ("target_id", "TEXT"), + ("reversible", "INTEGER NOT NULL DEFAULT 0"), + ("reversed_at", "TEXT"), +) + +REVERSIBLE_TABLES = { + "events": "Event", + "facts": "Fact", + "hypotheses": "Hypothesis", +} + + +class MemoryStoreError(RuntimeError): + """Raised when a requested memory operation would violate the contract.""" def get_db_paths(db_path_str): @@ -121,6 +146,134 @@ def now_iso(): return datetime.now(timezone.utc).isoformat() +def schema_is_current(conn): + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + required_tables = {"objects", "events", "facts", "hypotheses", "audit_log"} + if not required_tables.issubset(tables): + return False + columns = {row[1] for row in conn.execute("PRAGMA table_info(audit_log)")} + required_columns = {name for name, _definition in AUDIT_MIGRATION_COLUMNS} + version = conn.execute("PRAGMA user_version").fetchone()[0] + return version >= SCHEMA_VERSION and required_columns.issubset(columns) + + +def migrate_schema(conn): + """Create or migrate the schema atomically on an already-open connection.""" + if schema_is_current(conn): + return + previous_version = conn.execute("PRAGMA user_version").fetchone()[0] + conn.execute("BEGIN IMMEDIATE") + try: + for statement in CREATE_STATEMENTS: + conn.execute(statement) + + columns = { + row[1] for row in conn.execute("PRAGMA table_info(audit_log)") + } + for name, definition in AUDIT_MIGRATION_COLUMNS: + if name not in columns: + conn.execute( + f"ALTER TABLE audit_log ADD COLUMN {name} {definition}" + ) + + # Old audit rows did not identify their target record and duplicated + # sensitive content. Keep action/timestamp/object metadata, remove the + # copied body, and leave those rows explicitly non-reversible. + if previous_version < 2: + conn.execute( + "UPDATE audit_log SET detail=?, reversible=0 " + "WHERE target_table IS NULL AND target_id IS NULL", + ("Legacy audit retained without content; not reversible",), + ) + + # A historical forget implementation could delete an object while + # foreign keys were disabled, leaving sensitive orphan rows behind. + # Upgrade restores the promised forget semantics transactionally. + for table in ("events", "facts", "hypotheses"): + conn.execute( + f"DELETE FROM {table} WHERE NOT EXISTS " + f"(SELECT 1 FROM objects WHERE objects.id={table}.object_id)" + ) + conn.execute( + "DELETE FROM audit_log WHERE object_id IS NOT NULL AND NOT EXISTS " + "(SELECT 1 FROM objects WHERE objects.id=audit_log.object_id)" + ) + conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + conn.commit() + except Exception: + conn.rollback() + raise + + +def connect_database(db_path): + """Open a database with foreign-key enforcement and current schema.""" + conn = sqlite3.connect(str(db_path)) + try: + conn.execute("PRAGMA foreign_keys=ON") + enabled = conn.execute("PRAGMA foreign_keys").fetchone()[0] + if enabled != 1: + raise MemoryStoreError("Could not enable SQLite foreign-key enforcement.") + conn.execute("PRAGMA busy_timeout=5000") + migrate_schema(conn) + return conn + except Exception: + conn.close() + raise + + +def begin_write(conn): + conn.execute("BEGIN IMMEDIATE") + + +def require_object(conn, object_id, required_status=None): + row = conn.execute( + "SELECT id, code_name, status FROM objects WHERE id=?", (object_id,) + ).fetchone() + if row is None: + raise MemoryStoreError(f"Object not found: {object_id}") + if required_status is not None and row[2] != required_status: + if row[2] == "paused": + raise MemoryStoreError( + f"Object {object_id} is paused; resume it before recording data." + ) + raise MemoryStoreError( + f"Object {object_id} is {row[2]}; expected {required_status}." + ) + return row + + +def insert_audit( + conn, + action, + object_id, + detail, + *, + target_table=None, + target_id=None, + reversible=False, +): + conn.execute( + "INSERT INTO audit_log " + "(id, timestamp, action, object_id, detail, target_table, target_id, reversible) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + str(uuid.uuid4()), + now_iso(), + action, + object_id, + detail, + target_table, + target_id, + 1 if reversible else 0, + ), + ) + + def cmd_init(args): db_path, consent_path = get_db_paths(args.db_path) @@ -145,11 +298,12 @@ def cmd_init(args): print("Consent not given. Memory store NOT created.") return 1 - # Create database - conn = sqlite3.connect(str(db_path)) - conn.executescript(SCHEMA_SQL) - conn.commit() - conn.close() + # Create database. Every connection, including this one, enables foreign keys. + conn = connect_database(db_path) + try: + conn.execute("PRAGMA journal_mode=WAL") + finally: + conn.close() # Write consent file consent_data = { @@ -173,7 +327,7 @@ def cmd_status(args): print("No database found. Run 'init' first.") return 1 - conn = sqlite3.connect(str(db_path)) + conn = connect_database(db_path) conn.row_factory = sqlite3.Row cur = conn.cursor() @@ -208,14 +362,20 @@ def cmd_add_object(args): return 1 obj_id = str(uuid.uuid4()) - conn = sqlite3.connect(str(db_path)) - conn.execute("INSERT INTO objects (id, code_name, created_at) VALUES (?, ?, ?)", - (obj_id, args.code_name, now_iso())) - conn.execute("INSERT INTO audit_log (id, timestamp, action, object_id, detail) VALUES (?, ?, ?, ?, ?)", - (str(uuid.uuid4()), now_iso(), 'add-object', obj_id, - json.dumps({'code_name': args.code_name}, ensure_ascii=False))) - conn.commit() - conn.close() + conn = connect_database(db_path) + try: + begin_write(conn) + conn.execute( + "INSERT INTO objects (id, code_name, created_at) VALUES (?, ?, ?)", + (obj_id, args.code_name, now_iso()), + ) + insert_audit(conn, "add-object", obj_id, "Object created") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() print(f"Object created: {obj_id} ({args.code_name})") return 0 @@ -231,6 +391,9 @@ def cmd_add_event(args): if args.source not in SOURCE_CATEGORIES: print(f"ERROR: Invalid source '{args.source}'. Must be one of: {SOURCE_CATEGORIES}", file=sys.stderr) return 1 + if args.source == "unknown": + print("ERROR: Source 'unknown' is not writable; verify provenance first.", file=sys.stderr) + return 1 if args.confidence.upper() not in VALID_CONFIDENCE: print(f"ERROR: Invalid confidence '{args.confidence}'.", file=sys.stderr) return 1 @@ -241,15 +404,38 @@ def cmd_add_event(args): return 1 event_id = str(uuid.uuid4()) - conn = sqlite3.connect(str(db_path)) - conn.execute( - "INSERT INTO events (id, object_id, timestamp, event_type, summary, source, confidence) VALUES (?, ?, ?, ?, ?, ?, ?)", - (event_id, args.object_id, now_iso(), args.event_type, args.summary, args.source, args.confidence.upper())) - conn.execute("INSERT INTO audit_log (id, timestamp, action, object_id, detail) VALUES (?, ?, ?, ?, ?)", - (str(uuid.uuid4()), now_iso(), 'add-event', args.object_id, - json.dumps({'event_type': args.event_type, 'summary': args.summary}, ensure_ascii=False))) - conn.commit() - conn.close() + conn = connect_database(db_path) + try: + begin_write(conn) + require_object(conn, args.object_id, "active") + conn.execute( + "INSERT INTO events (id, object_id, timestamp, event_type, summary, source, confidence) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + event_id, + args.object_id, + now_iso(), + args.event_type, + args.summary, + args.source, + args.confidence.upper(), + ), + ) + insert_audit( + conn, + "add-event", + args.object_id, + json.dumps({"event_type": args.event_type}, ensure_ascii=False), + target_table="events", + target_id=event_id, + reversible=True, + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() print(f"Event recorded: {event_id}") return 0 @@ -265,6 +451,9 @@ def cmd_add_fact(args): if args.source_category not in SOURCE_CATEGORIES: print(f"ERROR: Invalid source_category '{args.source_category}'.", file=sys.stderr) return 1 + if args.source_category == "unknown": + print("ERROR: Source category 'unknown' is not writable; verify provenance first.", file=sys.stderr) + return 1 prohibited = check_prohibited(args.fact) if prohibited: @@ -272,15 +461,30 @@ def cmd_add_fact(args): return 1 fact_id = str(uuid.uuid4()) - conn = sqlite3.connect(str(db_path)) - conn.execute( - "INSERT INTO facts (id, object_id, fact, source_category, created_at) VALUES (?, ?, ?, ?, ?)", - (fact_id, args.object_id, args.fact, args.source_category, now_iso())) - conn.execute("INSERT INTO audit_log (id, timestamp, action, object_id, detail) VALUES (?, ?, ?, ?, ?)", - (str(uuid.uuid4()), now_iso(), 'add-fact', args.object_id, - json.dumps({'fact': args.fact, 'source_category': args.source_category}, ensure_ascii=False))) - conn.commit() - conn.close() + conn = connect_database(db_path) + try: + begin_write(conn) + require_object(conn, args.object_id, "active") + conn.execute( + "INSERT INTO facts (id, object_id, fact, source_category, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (fact_id, args.object_id, args.fact, args.source_category, now_iso()), + ) + insert_audit( + conn, + "add-fact", + args.object_id, + json.dumps({"source_category": args.source_category}, ensure_ascii=False), + target_table="facts", + target_id=fact_id, + reversible=True, + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() print(f"Fact recorded: {fact_id}") return 0 @@ -297,17 +501,53 @@ def cmd_add_hypothesis(args): print(f"ERROR: Invalid confidence '{args.confidence}'.", file=sys.stderr) return 1 + for field_name, value in ( + ("Hypothesis", args.hypothesis), + ("Basis", args.basis or ""), + ("Falsification", args.falsification or ""), + ): + prohibited = check_prohibited(value) + if prohibited: + print( + f"ERROR: {field_name} contains prohibited data: {', '.join(prohibited)}", + file=sys.stderr, + ) + return 1 + hyp_id = str(uuid.uuid4()) - conn = sqlite3.connect(str(db_path)) - conn.execute( - "INSERT INTO hypotheses (id, object_id, hypothesis, confidence, basis, falsification, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - (hyp_id, args.object_id, args.hypothesis, args.confidence.upper(), - args.basis or '', args.falsification or '', now_iso())) - conn.execute("INSERT INTO audit_log (id, timestamp, action, object_id, detail) VALUES (?, ?, ?, ?, ?)", - (str(uuid.uuid4()), now_iso(), 'add-hypothesis', args.object_id, - json.dumps({'hypothesis': args.hypothesis, 'confidence': args.confidence}, ensure_ascii=False))) - conn.commit() - conn.close() + conn = connect_database(db_path) + try: + begin_write(conn) + require_object(conn, args.object_id, "active") + conn.execute( + "INSERT INTO hypotheses " + "(id, object_id, hypothesis, confidence, basis, falsification, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + hyp_id, + args.object_id, + args.hypothesis, + args.confidence.upper(), + args.basis or "", + args.falsification or "", + now_iso(), + ), + ) + insert_audit( + conn, + "add-hypothesis", + args.object_id, + json.dumps({"confidence": args.confidence.upper()}, ensure_ascii=False), + target_table="hypotheses", + target_id=hyp_id, + reversible=True, + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() print(f"Hypothesis recorded: {hyp_id}") return 0 @@ -318,7 +558,7 @@ def cmd_query(args): print("ERROR: Database not found.", file=sys.stderr) return 1 - conn = sqlite3.connect(str(db_path)) + conn = connect_database(db_path) conn.row_factory = sqlite3.Row cur = conn.cursor() @@ -350,12 +590,23 @@ def cmd_pause(args): db_path, consent_path = get_db_paths(args.db_path) if not check_consent(consent_path): return 1 - conn = sqlite3.connect(str(db_path)) - conn.execute("UPDATE objects SET status='paused' WHERE id=?", (args.object_id,)) - conn.execute("INSERT INTO audit_log (id, timestamp, action, object_id, detail) VALUES (?, ?, ?, ?, ?)", - (str(uuid.uuid4()), now_iso(), 'pause', args.object_id, 'Object paused')) - conn.commit() - conn.close() + if not db_path.exists(): + print("ERROR: Database not found.", file=sys.stderr) + return 1 + conn = connect_database(db_path) + try: + begin_write(conn) + require_object(conn, args.object_id, "active") + conn.execute( + "UPDATE objects SET status='paused' WHERE id=?", (args.object_id,) + ) + insert_audit(conn, "pause", args.object_id, "Object paused") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() print(f"Object {args.object_id} paused.") return 0 @@ -364,12 +615,23 @@ def cmd_resume(args): db_path, consent_path = get_db_paths(args.db_path) if not check_consent(consent_path): return 1 - conn = sqlite3.connect(str(db_path)) - conn.execute("UPDATE objects SET status='active' WHERE id=?", (args.object_id,)) - conn.execute("INSERT INTO audit_log (id, timestamp, action, object_id, detail) VALUES (?, ?, ?, ?, ?)", - (str(uuid.uuid4()), now_iso(), 'resume', args.object_id, 'Object resumed')) - conn.commit() - conn.close() + if not db_path.exists(): + print("ERROR: Database not found.", file=sys.stderr) + return 1 + conn = connect_database(db_path) + try: + begin_write(conn) + require_object(conn, args.object_id, "paused") + conn.execute( + "UPDATE objects SET status='active' WHERE id=?", (args.object_id,) + ) + insert_audit(conn, "resume", args.object_id, "Object resumed") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() print(f"Object {args.object_id} resumed.") return 0 @@ -378,36 +640,77 @@ def cmd_undo_last(args): db_path, consent_path = get_db_paths(args.db_path) if not check_consent(consent_path): return 1 - conn = sqlite3.connect(str(db_path)) - cur = conn.cursor() - - # Find last audit entry for this object - last = cur.execute( - "SELECT * FROM audit_log WHERE object_id=? ORDER BY timestamp DESC LIMIT 1", - (args.object_id,)).fetchone() - if not last: - print("No operations to undo for this object.") - conn.close() + if not db_path.exists(): + print("ERROR: Database not found.", file=sys.stderr) return 1 + conn = connect_database(db_path) + try: + begin_write(conn) + require_object(conn, args.object_id) + last = conn.execute( + "SELECT id, timestamp, action, target_table, target_id " + "FROM audit_log " + "WHERE object_id=? AND reversible=1 AND reversed_at IS NULL " + "ORDER BY rowid DESC LIMIT 1", + (args.object_id,), + ).fetchone() + if last is None: + conn.rollback() + print("No reversible data writes remain for this object.") + return 1 + + audit_id, timestamp, action, target_table, target_id = last + if target_table not in REVERSIBLE_TABLES or not target_id: + raise MemoryStoreError("Latest reversible audit target is invalid.") + + print( + f"Last reversible write: {action} at {timestamp} " + f"({target_table}/{target_id})" + ) + print("Undo this data write? Type 'yes' to confirm:") + response = input("> ").strip().lower() + if response != "yes": + conn.rollback() + print("Undo cancelled.") + return 0 - print(f"Last operation: {last[2]} at {last[1]}") - print(f"Detail: {last[4]}") - print("Undo this operation? Type 'yes' to confirm:") - - response = input("> ").strip().lower() - if response != 'yes': - print("Undo cancelled.") + deleted = conn.execute( + f"DELETE FROM {target_table} WHERE id=? AND object_id=?", + (target_id, args.object_id), + ) + if deleted.rowcount != 1: + raise MemoryStoreError( + "The audited data record no longer exists; no changes were committed." + ) + conn.execute( + "UPDATE audit_log SET reversed_at=?, detail=? WHERE id=?", + ( + now_iso(), + json.dumps( + {"result": "reversed", "target_table": target_table}, + ensure_ascii=False, + ), + audit_id, + ), + ) + insert_audit( + conn, + "undo", + args.object_id, + json.dumps( + {"result": "data record deleted", "target_table": target_table}, + ensure_ascii=False, + ), + target_table=target_table, + target_id=target_id, + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: conn.close() - return 0 - - # Delete the audit entry (marks undo) - cur.execute("DELETE FROM audit_log WHERE id=?", (last[0],)) - conn.execute("INSERT INTO audit_log (id, timestamp, action, object_id, detail) VALUES (?, ?, ?, ?, ?)", - (str(uuid.uuid4()), now_iso(), 'undo', args.object_id, - f'Undid operation: {last[2]} at {last[1]}')) - conn.commit() - conn.close() - print("Last operation undone. Note: the actual data record was not automatically removed — verify and clean up manually if needed.") + print(f"Last reversible data write removed from {target_table}.") return 0 @@ -415,13 +718,19 @@ def cmd_forget_object(args): db_path, consent_path = get_db_paths(args.db_path) if not check_consent(consent_path): return 1 + if not db_path.exists(): + print("ERROR: Database not found.", file=sys.stderr) + return 1 - conn = sqlite3.connect(str(db_path)) - cur = conn.cursor() - obj = cur.execute("SELECT * FROM objects WHERE id=?", (args.object_id,)).fetchone() - if not obj: - print("Object not found.") + conn = connect_database(db_path) + try: + obj = conn.execute( + "SELECT id, code_name FROM objects WHERE id=?", (args.object_id,) + ).fetchone() + finally: conn.close() + if obj is None: + print("Object not found.") return 1 print(f"This will DELETE ALL data for object '{obj[1]}' (events, facts, hypotheses, audit log).") @@ -430,13 +739,26 @@ def cmd_forget_object(args): response = input("> ").strip().lower() if response != 'yes': print("Deletion cancelled.") - conn.close() return 0 - # Cascade delete via foreign keys - cur.execute("DELETE FROM objects WHERE id=?", (args.object_id,)) - conn.commit() - conn.close() + # Legacy audit tables may predate the audit foreign-key constraint. Remove + # those rows explicitly in the same transaction as the cascading delete. + conn = connect_database(db_path) + try: + begin_write(conn) + require_object(conn, args.object_id) + conn.execute("DELETE FROM audit_log WHERE object_id=?", (args.object_id,)) + deleted = conn.execute( + "DELETE FROM objects WHERE id=?", (args.object_id,) + ) + if deleted.rowcount != 1: + raise MemoryStoreError("Object disappeared before deletion completed.") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() print(f"Object {args.object_id} and all associated data permanently deleted.") return 0 @@ -475,7 +797,7 @@ def cmd_list(args): print("No database found.") return 1 - conn = sqlite3.connect(str(db_path)) + conn = connect_database(db_path) conn.row_factory = sqlite3.Row objects = conn.execute("SELECT id, code_name, status, created_at FROM objects").fetchall() diff --git a/scripts/normalize_timestamps.ps1 b/scripts/normalize_timestamps.ps1 index 74d30a2..938facb 100644 --- a/scripts/normalize_timestamps.ps1 +++ b/scripts/normalize_timestamps.ps1 @@ -1,4 +1,4 @@ -# normalize_timestamps.ps1 +# normalize_timestamps.ps1 # Normalize chat timestamps to ISO 8601 with Asia/Shanghai timezone. # Handles sec/ms unit detection, missing timezone, and future/past anomalies. param( diff --git a/scripts/redact_transcript.ps1 b/scripts/redact_transcript.ps1 index 2ea0c5c..2789b08 100644 --- a/scripts/redact_transcript.ps1 +++ b/scripts/redact_transcript.ps1 @@ -1,4 +1,4 @@ -# redact_transcript.ps1 +# redact_transcript.ps1 # De-identify chat transcripts: replace names, phone numbers, locations, and other PII with placeholders. param( [Parameter(Mandatory=$true)] diff --git a/scripts/search_classics.ps1 b/scripts/search_classics.ps1 index f718d60..568a897 100644 --- a/scripts/search_classics.ps1 +++ b/scripts/search_classics.ps1 @@ -1,4 +1,4 @@ -# search_classics.ps1 +# search_classics.ps1 # Search classical Chinese corpus for keywords, returning matching lines with context. # Part of the Read Between classical self-service layer. # Used by the agent for first-pass rough filtering before deep-reading original texts. diff --git a/scripts/tests/test_memory_store_behavior.py b/scripts/tests/test_memory_store_behavior.py new file mode 100644 index 0000000..1b558c7 --- /dev/null +++ b/scripts/tests/test_memory_store_behavior.py @@ -0,0 +1,496 @@ +from __future__ import annotations + +from contextlib import contextmanager +import json +import os +from pathlib import Path +import re +import sqlite3 +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +MEMORY_STORE = ROOT / "scripts" / "memory_store.py" +sys.path.insert(0, str(ROOT / "scripts")) + +import memory_store # noqa: E402 + + +@contextmanager +def open_database(path: Path): + connection = sqlite3.connect(path) + try: + yield connection + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + + +class MemoryStoreBehaviorTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.temp_dir.cleanup) + self.root = Path(self.temp_dir.name) + self.db = self.root / "memory.sqlite" + + def run_cli( + self, + *arguments: str, + stdin: str | None = None, + expected: int | None = 0, + ) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + env["PYTHONUTF8"] = "1" + result = subprocess.run( + [sys.executable, "-B", str(MEMORY_STORE), *arguments], + cwd=ROOT, + env=env, + input=stdin, + text=True, + encoding="utf-8", + capture_output=True, + timeout=30, + check=False, + ) + if expected is not None: + self.assertEqual( + result.returncode, + expected, + result.stdout + result.stderr, + ) + return result + + def initialize(self, db: Path | None = None) -> None: + target = db or self.db + self.run_cli("init", "--db-path", str(target), stdin="yes\n") + + def add_object(self, db: Path | None = None, code_name: str = "Synthetic-A") -> str: + target = db or self.db + result = self.run_cli( + "add-object", + "--db-path", + str(target), + "--code-name", + code_name, + ) + match = re.search(r"Object created:\s+([0-9a-f-]+)", result.stdout) + self.assertIsNotNone(match, result.stdout) + return match.group(1) + + @staticmethod + def counts(db: Path) -> dict[str, int]: + with open_database(db) as connection: + return { + table: connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + for table in ("objects", "events", "facts", "hypotheses", "audit_log") + } + + def add_event(self, object_id: str, expected: int | None = 0) -> subprocess.CompletedProcess[str]: + return self.run_cli( + "add-event", + "--db-path", + str(self.db), + "--object-id", + object_id, + "--event-type", + "important_conversation", + "--summary", + "synthetic event", + "--source", + "observed-event", + expected=expected, + ) + + def add_fact(self, object_id: str, fact: str = "synthetic fact", expected: int | None = 0) -> subprocess.CompletedProcess[str]: + return self.run_cli( + "add-fact", + "--db-path", + str(self.db), + "--object-id", + object_id, + "--fact", + fact, + "--source-category", + "user-confirmed", + expected=expected, + ) + + def add_hypothesis(self, object_id: str, expected: int | None = 0) -> subprocess.CompletedProcess[str]: + return self.run_cli( + "add-hypothesis", + "--db-path", + str(self.db), + "--object-id", + object_id, + "--hypothesis", + "synthetic inference", + "--basis", + "synthetic basis", + "--falsification", + "synthetic counterevidence", + expected=expected, + ) + + def test_pause_blocks_every_object_data_write_until_resume(self) -> None: + self.initialize() + object_id = self.add_object() + self.run_cli("pause", "--db-path", str(self.db), "--object-id", object_id) + + blocked = ( + self.add_event(object_id, expected=None), + self.add_fact(object_id, expected=None), + self.add_hypothesis(object_id, expected=None), + ) + for result in blocked: + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("paused", (result.stdout + result.stderr).lower()) + self.assertEqual( + self.counts(self.db), + {"objects": 1, "events": 0, "facts": 0, "hypotheses": 0, "audit_log": 2}, + ) + + self.run_cli("resume", "--db-path", str(self.db), "--object-id", object_id) + self.add_event(object_id) + self.add_fact(object_id) + self.add_hypothesis(object_id) + self.assertEqual( + self.counts(self.db), + {"objects": 1, "events": 1, "facts": 1, "hypotheses": 1, "audit_log": 6}, + ) + + def test_unknown_provenance_is_rejected_without_writing_data_or_audit(self) -> None: + self.initialize() + object_id = self.add_object() + event = self.run_cli( + "add-event", + "--db-path", + str(self.db), + "--object-id", + object_id, + "--summary", + "unattributed event", + "--source", + "unknown", + expected=None, + ) + fact = self.run_cli( + "add-fact", + "--db-path", + str(self.db), + "--object-id", + object_id, + "--fact", + "unattributed fact", + "--source-category", + "unknown", + expected=None, + ) + for result in (event, fact): + self.assertNotEqual(result.returncode, 0) + self.assertIn("unknown", (result.stdout + result.stderr).lower()) + self.assertEqual( + self.counts(self.db), + {"objects": 1, "events": 0, "facts": 0, "hypotheses": 0, "audit_log": 1}, + ) + + def test_undo_last_reverses_each_data_write_and_keeps_a_non_sensitive_audit(self) -> None: + self.initialize() + object_id = self.add_object() + self.add_fact(object_id, "private synthetic fact") + self.add_event(object_id) + + self.run_cli( + "undo-last", + "--db-path", + str(self.db), + "--object-id", + object_id, + stdin="yes\n", + ) + counts = self.counts(self.db) + self.assertEqual((counts["events"], counts["facts"]), (0, 1)) + + with open_database(self.db) as connection: + connection.row_factory = sqlite3.Row + rows = connection.execute( + "SELECT action, target_table, target_id, reversible, reversed_at, detail " + "FROM audit_log WHERE object_id=? ORDER BY rowid", + (object_id,), + ).fetchall() + event_audit = next(row for row in rows if row["action"] == "add-event") + self.assertEqual(event_audit["target_table"], "events") + self.assertTrue(event_audit["target_id"]) + self.assertEqual(event_audit["reversible"], 1) + self.assertTrue(event_audit["reversed_at"]) + self.assertNotIn("synthetic event", event_audit["detail"]) + undo_audit = rows[-1] + self.assertEqual(undo_audit["action"], "undo") + self.assertEqual(undo_audit["target_table"], "events") + self.assertEqual(undo_audit["target_id"], event_audit["target_id"]) + + self.run_cli( + "undo-last", + "--db-path", + str(self.db), + "--object-id", + object_id, + stdin="yes\n", + ) + counts = self.counts(self.db) + self.assertEqual((counts["events"], counts["facts"]), (0, 0)) + with open_database(self.db) as connection: + audit_text = "\n".join( + row[0] or "" for row in connection.execute("SELECT detail FROM audit_log") + ) + self.assertNotIn("private synthetic fact", audit_text) + + no_more = self.run_cli( + "undo-last", + "--db-path", + str(self.db), + "--object-id", + object_id, + stdin="yes\n", + expected=None, + ) + self.assertNotEqual(no_more.returncode, 0) + self.assertIn("reversible", (no_more.stdout + no_more.stderr).lower()) + + def test_legacy_database_migrates_and_new_writes_are_reversible(self) -> None: + with open_database(self.db) as connection: + connection.executescript( + """ + CREATE TABLE objects ( + id TEXT PRIMARY KEY, + code_name TEXT NOT NULL, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + ); + CREATE TABLE facts ( + id TEXT PRIMARY KEY, + object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + fact TEXT NOT NULL, + source_category TEXT NOT NULL, + verified TEXT NOT NULL DEFAULT 'unverified', + created_at TEXT NOT NULL, + superseded_by TEXT + ); + CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + action TEXT NOT NULL, + object_id TEXT, + detail TEXT + ); + INSERT INTO objects VALUES ('legacy-object', 'Legacy', '2026-01-01T00:00:00Z', 'active'); + INSERT INTO facts VALUES ( + 'legacy-fact', 'legacy-object', 'legacy data', 'user-confirmed', + 'unverified', '2026-01-01T00:00:00Z', NULL + ); + INSERT INTO audit_log VALUES ( + 'legacy-audit', '2026-01-01T00:00:00Z', 'add-fact', + 'legacy-object', '{"fact":"legacy data"}' + ); + """ + ) + self.db.with_suffix(".consent.json").write_text( + json.dumps({"scope": "legacy opt-in"}), + encoding="utf-8", + ) + + self.add_fact("legacy-object", "new reversible fact") + self.run_cli( + "undo-last", + "--db-path", + str(self.db), + "--object-id", + "legacy-object", + stdin="yes\n", + ) + + with open_database(self.db) as connection: + columns = { + row[1] for row in connection.execute("PRAGMA table_info(audit_log)") + } + facts = connection.execute( + "SELECT id, fact FROM facts ORDER BY id" + ).fetchall() + legacy_audit = connection.execute( + "SELECT reversible, target_id, detail FROM audit_log WHERE id='legacy-audit'" + ).fetchone() + self.assertTrue( + {"target_table", "target_id", "reversible", "reversed_at"}.issubset(columns) + ) + self.assertEqual(facts, [("legacy-fact", "legacy data")]) + self.assertEqual(legacy_audit[0:2], (0, None)) + self.assertNotIn("legacy data", legacy_audit[2]) + self.assertIn("not reversible", legacy_audit[2]) + + def test_migration_purges_orphans_left_by_legacy_forget_behavior(self) -> None: + with open_database(self.db) as connection: + for statement in memory_store.CREATE_STATEMENTS: + connection.execute(statement) + connection.execute( + "INSERT INTO facts " + "(id, object_id, fact, source_category, created_at) " + "VALUES ('orphan-fact', 'forgotten-object', 'private orphan', " + "'user-confirmed', '2026-01-01T00:00:00Z')" + ) + connection.execute( + "INSERT INTO audit_log " + "(id, timestamp, action, object_id, detail) " + "VALUES ('orphan-audit', '2026-01-01T00:00:00Z', 'add-fact', " + "'forgotten-object', '{\"fact\":\"private orphan\"}')" + ) + connection.execute("PRAGMA user_version=1") + + self.run_cli("status", "--db-path", str(self.db), expected=0) + self.assertEqual( + self.counts(self.db), + {"objects": 0, "events": 0, "facts": 0, "hypotheses": 0, "audit_log": 0}, + ) + with open_database(self.db) as connection: + self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), []) + + def test_forget_object_cascades_after_reopen_and_removes_its_audit(self) -> None: + self.initialize() + object_id = self.add_object() + self.add_event(object_id) + self.add_fact(object_id) + self.add_hypothesis(object_id) + self.run_cli("pause", "--db-path", str(self.db), "--object-id", object_id) + + self.run_cli( + "forget-object", + "--db-path", + str(self.db), + "--object-id", + object_id, + stdin="yes\n", + ) + + self.assertEqual( + self.counts(self.db), + {"objects": 0, "events": 0, "facts": 0, "hypotheses": 0, "audit_log": 0}, + ) + with open_database(self.db) as reopened: + self.assertEqual(reopened.execute("PRAGMA foreign_key_check").fetchall(), []) + + def test_forget_object_rolls_back_if_a_cascade_delete_fails(self) -> None: + self.initialize() + object_id = self.add_object() + self.add_fact(object_id) + before = self.counts(self.db) + with open_database(self.db) as connection: + connection.execute( + "CREATE TRIGGER block_fact_delete BEFORE DELETE ON facts " + "BEGIN SELECT RAISE(ABORT, 'injected cascade failure'); END" + ) + + failed = self.run_cli( + "forget-object", + "--db-path", + str(self.db), + "--object-id", + object_id, + stdin="yes\n", + expected=None, + ) + self.assertNotEqual(failed.returncode, 0) + self.assertEqual(self.counts(self.db), before) + + def test_undo_rolls_back_data_delete_if_audit_insert_fails(self) -> None: + self.initialize() + object_id = self.add_object() + self.add_event(object_id) + with open_database(self.db) as connection: + connection.execute( + "CREATE TRIGGER block_undo_audit BEFORE INSERT ON audit_log " + "WHEN NEW.action='undo' " + "BEGIN SELECT RAISE(ABORT, 'injected undo audit failure'); END" + ) + + failed = self.run_cli( + "undo-last", + "--db-path", + str(self.db), + "--object-id", + object_id, + stdin="yes\n", + expected=None, + ) + self.assertNotEqual(failed.returncode, 0) + with open_database(self.db) as connection: + self.assertEqual(connection.execute("SELECT COUNT(*) FROM events").fetchone()[0], 1) + audit = connection.execute( + "SELECT reversed_at FROM audit_log WHERE action='add-event'" + ).fetchone() + self.assertIsNotNone(audit) + self.assertIsNone(audit[0]) + + def test_schema_migration_rolls_back_if_an_alter_is_denied(self) -> None: + with open_database(self.db) as connection: + connection.executescript( + """ + CREATE TABLE objects ( + id TEXT PRIMARY KEY, + code_name TEXT NOT NULL, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + ); + CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + action TEXT NOT NULL, + object_id TEXT, + detail TEXT + ); + """ + ) + + connection = sqlite3.connect(self.db) + alter_calls = 0 + + def deny_second_alter(action, _arg1, _arg2, _database, _trigger): + nonlocal alter_calls + if action == sqlite3.SQLITE_ALTER_TABLE: + alter_calls += 1 + if alter_calls == 2: + return sqlite3.SQLITE_DENY + return sqlite3.SQLITE_OK + + try: + connection.set_authorizer(deny_second_alter) + with self.assertRaises(sqlite3.DatabaseError): + memory_store.migrate_schema(connection) + # Python 3.10's sqlite3 wrapper does not reliably clear an + # authorizer with None on every platform. Install an explicit + # allow-all callback before inspecting the rolled-back schema. + connection.set_authorizer(lambda *_args: sqlite3.SQLITE_OK) + columns = { + row[1] for row in connection.execute("PRAGMA table_info(audit_log)") + } + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + finally: + connection.close() + self.assertGreaterEqual(alter_calls, 2) + self.assertNotIn("target_table", columns) + self.assertNotIn("events", tables) + self.assertNotIn("facts", tables) + self.assertNotIn("hypotheses", tables) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_powershell_compat.py b/scripts/tests/test_powershell_compat.py new file mode 100644 index 0000000..29928ff --- /dev/null +++ b/scripts/tests/test_powershell_compat.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +COMPATIBILITY_VALIDATOR = ROOT / "scripts" / "validate_powershell_compat.py" + + +def powershell_executable() -> str | None: + if os.name == "nt": + return shutil.which("powershell.exe") + return shutil.which("pwsh") + + +class PowerShellCompatibilityTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.powershell = powershell_executable() + if cls.powershell is None: + raise unittest.SkipTest("PowerShell is unavailable on this platform") + + def run_powershell( + self, *arguments: str, expected: int = 0 + ) -> subprocess.CompletedProcess[str]: + command = [self.powershell, "-NoLogo", "-NoProfile", "-NonInteractive"] + if Path(self.powershell).name.lower() == "powershell.exe": + command.extend(("-ExecutionPolicy", "Bypass")) + command.extend(arguments) + result = subprocess.run( + command, + cwd=ROOT, + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + timeout=60, + check=False, + ) + self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + return result + + def test_all_tracked_powershell_scripts_parse(self) -> None: + tracked = subprocess.run( + ["git", "ls-files", "--", "*.ps1"], + cwd=ROOT, + text=True, + encoding="utf-8", + capture_output=True, + check=True, + ).stdout.splitlines() + self.assertTrue(tracked) + parser = ( + "$tokens=$null; $errors=$null; " + "[System.Management.Automation.Language.Parser]::ParseFile(" + "$env:RB_PS_PARSE_TARGET, [ref]$tokens, [ref]$errors) | Out-Null; " + "if ($errors.Count -gt 0) { " + "$errors | ForEach-Object { [Console]::Error.WriteLine($_.ToString()) }; " + "exit 1 }" + ) + failures = [] + for relative in tracked: + environment = os.environ.copy() + environment["RB_PS_PARSE_TARGET"] = str(ROOT / relative) + result = subprocess.run( + [ + self.powershell, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + parser, + ], + cwd=ROOT, + env=environment, + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + timeout=30, + check=False, + ) + if result.returncode != 0: + failures.append(f"{relative}: {result.stdout}{result.stderr}") + self.assertEqual(failures, [], "\n".join(failures)) + + def test_redaction_and_memory_validation_smokes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + transcript = temporary / "transcript.txt" + redacted = temporary / "redacted.txt" + transcript.write_text( + "SyntheticName called 13800138000.\n", + encoding="utf-8-sig", + ) + self.run_powershell( + "-File", + str(ROOT / "scripts" / "redact_transcript.ps1"), + "-InputFile", + str(transcript), + "-OutputFile", + str(redacted), + "-NamesToRedact", + "SyntheticName", + ) + content = redacted.read_text(encoding="utf-8-sig") + self.assertNotIn("SyntheticName", content) + self.assertNotIn("13800138000", content) + self.assertIn("[Person_1]", content) + self.assertIn("[REDACTED_PHONE]", content) + + self.run_powershell( + "-File", + str(ROOT / "scripts" / "validate_memory_store.ps1"), + "-DataRoot", + str(temporary / "not-initialized"), + ) + + def test_contract_entry_point_smoke(self) -> None: + self.run_powershell( + "-File", str(ROOT / "scripts" / "validate_contract.ps1") + ) + + def test_release_compatibility_validator_runs_all_checks(self) -> None: + result = subprocess.run( + [ + sys.executable, + "-B", + str(COMPATIBILITY_VALIDATOR), + "--skill-root", + str(ROOT), + ], + cwd=ROOT, + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + timeout=120, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PowerShell compatibility validation passed", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_public_product_contract.py b/scripts/tests/test_public_product_contract.py new file mode 100644 index 0000000..b11be0a --- /dev/null +++ b/scripts/tests/test_public_product_contract.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import hashlib +import json +import os +import stat +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import build_plugin_package as plugin_builder # noqa: E402 +import validate_skill # noqa: E402 + + +def tree_digest(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(item for item in root.rglob("*") if item.is_file()): + metadata = path.stat() + digest.update(path.relative_to(root).as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + digest.update(str(stat.S_IMODE(metadata.st_mode)).encode("ascii")) + digest.update(b"\0") + digest.update(str(metadata.st_mtime_ns).encode("ascii")) + digest.update(b"\0") + return digest.hexdigest() + + +class PublicProductContractTests(unittest.TestCase): + def build( + self, + output: Path, + *, + zip_output: Path | None = None, + codex_home: Path | None = None, + ) -> subprocess.CompletedProcess[str]: + command = [ + sys.executable, + str(SCRIPTS / "build_plugin_package.py"), + "--output", + str(output), + ] + if zip_output is not None: + command.extend(("--zip-output", str(zip_output))) + if codex_home is not None: + command.extend(("--codex-home", str(codex_home))) + return subprocess.run( + command, + cwd=ROOT, + text=True, + encoding="utf-8", + capture_output=True, + timeout=60, + check=False, + ) + + def test_readmes_publish_exact_install_invocation_examples_and_release(self) -> None: + for filename in ("README.md", "README.en.md"): + text = (ROOT / filename).read_text(encoding="utf-8") + with self.subTest(filename=filename): + self.assertIn("git clone https://github.com/Roblis0n/Read-between.git", text) + self.assertIn("$read-between", text) + self.assertIn("examples/ambiguous-signal.md", text) + self.assertIn("github/v/release", text) + self.assertIn( + "--output ../read-between-release/read-between", text + ) + self.assertIn("read-between.zip", text) + self.assertNotIn("version-v4.0", text) + + practical_files = list((ROOT / "references" / "practical").glob("*.md")) + self.assertEqual(len(practical_files), 24) + self.assertIn("23 份场景指南 + 1 份伦理使用边界", (ROOT / "README.md").read_text(encoding="utf-8")) + + def test_examples_pass_behavior_checks_and_unsafe_mutations_fail(self) -> None: + self.assertEqual(validate_skill.public_example_errors(ROOT / "examples"), []) + + with tempfile.TemporaryDirectory() as tmp: + mutated = Path(tmp) + for source in (ROOT / "examples").glob("*.md"): + (mutated / source.name).write_text( + source.read_text(encoding="utf-8"), encoding="utf-8" + ) + + ambiguous = mutated / "ambiguous-signal.md" + ambiguous.write_text( + ambiguous.read_text(encoding="utf-8") + + "\n关系判断:观察窗口等 7 天后就决定退出。\n", + encoding="utf-8", + ) + errors = validate_skill.public_example_errors(mutated) + self.assertTrue(any("fixed relationship window" in item for item in errors)) + + ambiguous.write_text( + (ROOT / "examples" / "ambiguous-signal.md").read_text(encoding="utf-8") + + "\n建议偷偷查看对方手机。\n", + encoding="utf-8", + ) + errors = validate_skill.public_example_errors(mutated) + self.assertTrue(any("unsafe affirmative advice" in item for item in errors)) + + safety = mutated / "safety-and-privacy.md" + safety.write_text( + safety.read_text(encoding="utf-8") + + "\n```text\n你先冷静,我下楼和你谈。\n```\n", + encoding="utf-8", + ) + errors = validate_skill.public_example_errors(mutated) + self.assertTrue(any("expected 1 text block" in item for item in errors)) + + def test_full_validator_has_no_errors_or_advisory_noise(self) -> None: + result = subprocess.run( + [sys.executable, str(SCRIPTS / "validate_skill.py")], + cwd=ROOT, + text=True, + encoding="utf-8", + capture_output=True, + timeout=60, + check=False, + ) + output = result.stdout + result.stderr + self.assertEqual(result.returncode, 0, output) + self.assertNotIn("[WARN]", output) + self.assertNotIn("[ERROR]", output) + + def test_changelog_marks_the_v420_release(self) -> None: + changelog = (ROOT / "CHANGELOG-v2.md").read_text(encoding="utf-8") + + self.assertIn("## v4.2.0 — 2026-08-13", changelog) + self.assertNotIn("## v4.2.0 — Unreleased", changelog) + + def test_plugin_build_is_canonical_clean_deterministic_and_safe(self) -> None: + manifest = json.loads((ROOT / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["version"], "4.2.0") + self.assertIn("$read-between", manifest["interface"]["defaultPrompt"][0]) + + with tempfile.TemporaryDirectory() as tmp: + tmp_root = Path(tmp) + first = tmp_root / "first" / "read-between" + second = tmp_root / "second" / "read-between" + first_result = self.build(first) + self.assertEqual( + first_result.returncode, 0, first_result.stdout + first_result.stderr + ) + second_result = self.build(second) + self.assertEqual( + second_result.returncode, 0, second_result.stdout + second_result.stderr + ) + self.assertEqual(tree_digest(first), tree_digest(second)) + first_zip = first.with_suffix(".zip") + second_zip = second.with_suffix(".zip") + self.assertTrue(first_zip.is_file()) + self.assertTrue(second_zip.is_file()) + self.assertEqual( + hashlib.sha256(first_zip.read_bytes()).hexdigest(), + hashlib.sha256(second_zip.read_bytes()).hexdigest(), + ) + with zipfile.ZipFile(first_zip) as archive: + infos = archive.infolist() + self.assertTrue(infos) + self.assertTrue( + all(info.filename.startswith("read-between/") for info in infos) + ) + self.assertEqual({info.date_time for info in infos}, {(2000, 1, 1, 0, 0, 0)}) + file_modes = { + (info.external_attr >> 16) & 0o777 + for info in infos + if not info.is_dir() + } + self.assertTrue(file_modes.issubset({0o644, 0o755})) + + packaged_skill = first / "skills" / "read-between" + staged_skill = subprocess.run( + ["git", "show", ":SKILL.md"], + cwd=ROOT, + capture_output=True, + check=True, + ).stdout + self.assertEqual((packaged_skill / "SKILL.md").read_bytes(), staged_skill) + self.assertTrue((packaged_skill / "scripts" / "validate_skill.py").is_file()) + self.assertFalse((packaged_skill / "scripts" / "build_plugin_package.py").exists()) + self.assertFalse((packaged_skill / "scripts" / "tests").exists()) + self.assertFalse(any(path.name == "__pycache__" for path in first.rglob("*"))) + self.assertFalse(any(path.suffix == ".pyc" for path in first.rglob("*"))) + self.assertTrue((first / "assets" / "social-preview.png").is_file()) + self.assertTrue((packaged_skill / "examples" / "ambiguous-signal.md").is_file()) + self.assertTrue((packaged_skill / ".github" / "SECURITY.md").is_file()) + + validation = subprocess.run( + [sys.executable, str(packaged_skill / "scripts" / "validate_skill.py")], + cwd=packaged_skill, + text=True, + encoding="utf-8", + capture_output=True, + timeout=60, + check=False, + ) + self.assertEqual(validation.returncode, 0, validation.stdout + validation.stderr) + self.assertNotIn("[WARN]", validation.stdout + validation.stderr) + + existing = self.build(first) + self.assertNotEqual(existing.returncode, 0) + self.assertIn("new directory", existing.stdout + existing.stderr) + + in_source = self.build(ROOT / "dist" / "read-between") + self.assertNotEqual(in_source.returncode, 0) + self.assertIn("outside the source root", in_source.stdout + in_source.stderr) + self.assertFalse((ROOT / "dist" / "read-between").exists()) + + def test_plugin_build_ignores_untracked_source_material_and_cleans_failures(self) -> None: + private_note = ROOT / "references" / "local-private-note.txt" + self.assertFalse(private_note.exists()) + private_note.write_text("private fixture that must never ship", encoding="utf-8") + try: + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "read-between" + result = self.build(output) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse(any(path.name == private_note.name for path in output.rglob("*"))) + finally: + private_note.unlink(missing_ok=True) + + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "read-between" + with mock.patch.object( + plugin_builder, + "staged_bytes", + side_effect=OSError("injected staged-blob failure"), + ): + with self.assertRaises(OSError): + plugin_builder.build_plugin(output) + self.assertFalse(output.exists()) + self.assertEqual(list(output.parent.glob(f".{output.name}.tmp-*")), []) + + def test_plugin_builder_rejects_wrong_basename_and_invalid_staged_manifest(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + wrong_name = Path(tmp) / "plugin" + result = self.build(wrong_name) + self.assertNotEqual(result.returncode, 0) + self.assertIn("basename", result.stdout + result.stderr) + self.assertFalse(wrong_name.exists()) + + original_staged_bytes = plugin_builder.staged_bytes + + def invalid_manifest(path: str) -> bytes: + if path == ".codex-plugin/plugin.json": + return b'{"name":"read-between","version":"4.2.0"}' + return original_staged_bytes(path) + + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "read-between" + with mock.patch.object( + plugin_builder, "staged_bytes", side_effect=invalid_manifest + ): + with self.assertRaisesRegex(ValueError, "plugin manifest"): + plugin_builder.build_plugin(output) + self.assertFalse(output.exists()) + self.assertFalse(output.with_suffix(".zip").exists()) + + with tempfile.TemporaryDirectory() as tmp: + plugin_root = Path(tmp) / "read-between" + (plugin_root / ".codex-plugin").mkdir(parents=True) + (plugin_root / "assets").mkdir() + skill_root = plugin_root / "skills" / "read-between" + skill_root.mkdir(parents=True) + manifest = json.loads( + (ROOT / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["skills"] = "../skills" + (plugin_root / ".codex-plugin" / "plugin.json").write_text( + json.dumps(manifest), encoding="utf-8" + ) + for asset in ("icon-small.png", "icon-large.png", "social-preview.png"): + (plugin_root / "assets" / asset).write_bytes( + (ROOT / "assets" / asset).read_bytes() + ) + (skill_root / "SKILL.md").write_bytes((ROOT / "SKILL.md").read_bytes()) + with self.assertRaisesRegex(ValueError, "skills"): + plugin_builder.validate_builtin_plugin(plugin_root) + + def test_optional_official_validators_run_against_staging(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + temporary = Path(tmp) + codex_home = temporary / "codex-home" + marker = temporary / "official-validator-targets.txt" + validators = ( + codex_home + / "skills" + / ".system" + / "skill-creator" + / "scripts" + / "quick_validate.py", + codex_home + / "skills" + / ".system" + / "plugin-creator" + / "scripts" + / "validate_plugin.py", + ) + for validator in validators: + validator.parent.mkdir(parents=True, exist_ok=True) + validator.write_text( + "from pathlib import Path\n" + "import sys\n" + f"Path({str(marker)!r}).open('a', encoding='utf-8').write(sys.argv[1] + '\\n')\n", + encoding="utf-8", + ) + output = temporary / "artifact" / "read-between" + result = self.build(output, codex_home=codex_home) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + targets = marker.read_text(encoding="utf-8").splitlines() + self.assertEqual(len(targets), 2) + self.assertTrue(targets[0].endswith("skills\\read-between") or targets[0].endswith("skills/read-between")) + self.assertTrue(".read-between.tmp-" in targets[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_validate_skill_cli.py b/scripts/tests/test_validate_skill_cli.py new file mode 100644 index 0000000..0532f04 --- /dev/null +++ b/scripts/tests/test_validate_skill_cli.py @@ -0,0 +1,200 @@ +from contextlib import redirect_stdout +import importlib.util +import io +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +SKILL_ROOT = Path(__file__).resolve().parents[2] +VALIDATOR = SKILL_ROOT / "scripts" / "validate_skill.py" + + +def load_validator_module(): + spec = importlib.util.spec_from_file_location("read_between_validate_skill", VALIDATOR) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class ValidateSkillCliTests(unittest.TestCase): + def test_cli_emits_utf8_when_inherited_encoding_is_cp1252(self): + env = os.environ.copy() + env["PYTHONIOENCODING"] = "cp1252" + env["PYTHONUTF8"] = "0" + + result = subprocess.run( + [sys.executable, str(VALIDATOR), "--runtime"], + cwd=SKILL_ROOT, + env=env, + capture_output=True, + check=False, + ) + + stdout = result.stdout.decode("utf-8") + stderr = result.stderr.decode("utf-8") + self.assertEqual(result.returncode, 0, stderr) + self.assertIn("=== Validation Report ===", stdout) + + +class ValidateSkillBehaviorTests(unittest.TestCase): + def setUp(self): + self.validator = load_validator_module() + self.temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.temp_dir.cleanup) + self.root = Path(self.temp_dir.name) + self.validator.SKILL_ROOT = self.root + self.validator.ERRORS = [] + self.validator.WARNINGS = [] + + def write(self, relative_path, content): + path = self.root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + @staticmethod + def rel(relative_path): + return str(Path(relative_path)) + + def test_placeholder_scan_ignores_detector_literals_but_reports_real_placeholders(self): + self.write( + "scripts/validate_record.ps1", + "if ($body -match '\\[TODO\\]|\\[FIXME\\]') {\n" + ' $warnings += "body contains placeholder text ([TODO] or [FIXME])"\n' + "}\n", + ) + self.write( + "scripts/validate_skill.py", + "PLACEHOLDER_RE = re.compile(r'(? str: + if explicit: + resolved = shutil.which(explicit) or ( + explicit if Path(explicit).is_file() else None + ) + elif os.name == "nt": + resolved = shutil.which("powershell.exe") + else: + resolved = shutil.which("pwsh") + if not resolved: + expected = "powershell.exe (Windows PowerShell 5.1)" if os.name == "nt" else "pwsh" + raise RuntimeError(f"Required PowerShell executable not found: {expected}") + return str(Path(resolved).resolve()) + + +def powershell_prefix(executable: str) -> list[str]: + command = [executable, "-NoLogo", "-NoProfile", "-NonInteractive"] + if Path(executable).name.lower() == "powershell.exe": + command.extend(("-ExecutionPolicy", "Bypass")) + return command + + +def run_checked(command: list[str], cwd: Path, env: dict[str, str] | None = None) -> str: + result = subprocess.run( + command, + cwd=cwd, + env=env, + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + timeout=120, + check=False, + ) + if result.returncode != 0: + rendered = " ".join(command) + raise RuntimeError( + f"Command failed ({result.returncode}): {rendered}\n" + f"{result.stdout}{result.stderr}" + ) + return result.stdout + + +def validate_parse(skill_root: Path, executable: str) -> int: + scripts = sorted(skill_root.rglob("*.ps1")) + if not scripts: + raise RuntimeError(f"No PowerShell scripts found under {skill_root}") + parser = ( + "$tokens=$null; $errors=$null; " + "[System.Management.Automation.Language.Parser]::ParseFile(" + "$env:RB_PS_PARSE_TARGET, [ref]$tokens, [ref]$errors) | Out-Null; " + "if ($errors.Count -gt 0) { " + "$errors | ForEach-Object { [Console]::Error.WriteLine($_.ToString()) }; " + "exit 1 }" + ) + for script in scripts: + environment = os.environ.copy() + environment["RB_PS_PARSE_TARGET"] = str(script.resolve()) + run_checked( + powershell_prefix(executable) + ["-Command", parser], + skill_root, + environment, + ) + return len(scripts) + + +def validate_smokes(skill_root: Path, executable: str) -> None: + scripts_dir = skill_root / "scripts" + required = ( + scripts_dir / "validate_contract.ps1", + scripts_dir / "redact_transcript.ps1", + scripts_dir / "validate_memory_store.ps1", + ) + missing = [str(path) for path in required if not path.is_file()] + if missing: + raise RuntimeError("Missing PowerShell smoke entry points: " + ", ".join(missing)) + + prefix = powershell_prefix(executable) + run_checked(prefix + ["-File", str(required[0])], skill_root) + + with tempfile.TemporaryDirectory(prefix="read-between-ps-") as directory: + temporary = Path(directory) + transcript = temporary / "transcript.txt" + redacted = temporary / "redacted.txt" + transcript.write_text( + "SyntheticName called 13800138000.\n", + encoding="utf-8-sig", + ) + run_checked( + prefix + + [ + "-File", + str(required[1]), + "-InputFile", + str(transcript), + "-OutputFile", + str(redacted), + "-NamesToRedact", + "SyntheticName", + ], + skill_root, + ) + content = redacted.read_text(encoding="utf-8-sig") + for secret in ("SyntheticName", "13800138000"): + if secret in content: + raise RuntimeError(f"Redaction smoke leaked fixture value: {secret}") + for placeholder in ("[Person_1]", "[REDACTED_PHONE]"): + if placeholder not in content: + raise RuntimeError( + f"Redaction smoke omitted expected placeholder: {placeholder}" + ) + + run_checked( + prefix + + [ + "-File", + str(required[2]), + "-DataRoot", + str(temporary / "not-initialized"), + ], + skill_root, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--skill-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="Root Skill or staged plugin Skill directory", + ) + parser.add_argument( + "--powershell", + help="Explicit PowerShell executable (mainly for controlled CI)", + ) + args = parser.parse_args(argv) + + skill_root = args.skill_root.resolve() + if not (skill_root / "SKILL.md").is_file(): + print(f"ERROR: Not a read-between Skill root: {skill_root}", file=sys.stderr) + return 2 + try: + executable = find_powershell(args.powershell) + count = validate_parse(skill_root, executable) + validate_smokes(skill_root, executable) + except (OSError, RuntimeError, subprocess.SubprocessError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print( + f"PowerShell compatibility validation passed: {count} scripts; " + "parser, contract, redaction, and memory smokes." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate_record.ps1 b/scripts/validate_record.ps1 index f19cec1..9121119 100644 --- a/scripts/validate_record.ps1 +++ b/scripts/validate_record.ps1 @@ -1,4 +1,4 @@ -# validate_record.ps1 +# validate_record.ps1 # Validate a single memory record: check required YAML frontmatter fields, stable ID, status, and basis. param( [Parameter(Mandatory=$true)] diff --git a/scripts/validate_skill.py b/scripts/validate_skill.py index 29bb720..d557b4b 100644 --- a/scripts/validate_skill.py +++ b/scripts/validate_skill.py @@ -13,10 +13,20 @@ """ import argparse +import ast import re import sys from pathlib import Path + +def configure_output_encoding(): + """Keep CLI diagnostics writable and machine-readable on legacy consoles.""" + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, 'reconfigure', None) + if reconfigure: + reconfigure(encoding='utf-8', errors='replace') + + # --- Configurable Constants --- SKILL_MD_MAX_LINES = 310 @@ -24,7 +34,13 @@ SKILL_MD_MAX_TOKENS = 5200 RUNTIME_ALLOWLIST = {'SKILL.md', 'agents', 'references', 'scripts', 'assets'} -DEV_DIRS = {'documentation', 'tests', 'advice', 'chatlog', 'show', 'love-me-research', 'goutoujunshi-main'} +PUBLIC_ROOT_ITEMS = {'README.md', 'README.en.md', 'ARCHITECTURE.md', + 'CHANGELOG-v1.md', 'CHANGELOG-v2.md', 'LICENSE', + '.gitignore', 'docs', 'examples'} +DEV_DIRS = { + '.superpowers', 'documentation', 'tests', 'advice', 'chatlog', 'show', + 'love-me-research', 'goutoujunshi-main', +} EXPECTED_AGENT_FILE = 'agents/openai.yaml' @@ -57,9 +73,37 @@ 'normalize_timestamps.ps1', 'redact_transcript.ps1', 'validate_record.ps1', 'validate_memory_store.ps1', 'detect_cross_relationship_leakage.ps1', 'search_classics.ps1', - 'memory_store.py', 'validate_skill.py', + 'memory_store.py', 'validate_skill.py', 'validate_powershell_compat.py', ] +PUBLIC_EXAMPLES = { + 'ambiguous-signal.md': ( + ('事实', '可确认'), ('推测', '合理推测'), ('未知', '关键未知'), + ('推翻', '可证伪'), ('观察窗口',), ('停止',), + ), + 'boundary-and-exit.md': ( + ('事实', '可确认'), ('推测', '合理推测'), ('未知', '关键未知'), + ('用户利益',), ('首选建议',), ('行动闭合',), ('退出',), + ), + 'safety-and-privacy.md': ( + ('事实', '可确认'), ('未知', '关键未知'), ('安全',), ('证据',), + ('隐私',), ('脱敏',), ('原始',), + ), +} + +EXAMPLE_PII_PATTERNS = ( + (re.compile(r'(?= 2: + draft = text_blocks[1].strip() + if not draft or len(draft) > 240: + findings.append(f"{filename}: drafted message is missing or not concise") + if any(token in draft for token in ('必须回复', '不然', '否则', '让你后悔')): + findings.append(f"{filename}: drafted message contains pressure or a threat") + return findings + + +def check_public_examples(): + """Validate examples as runnable safety behavior, not only prose presence.""" + print("\n--- Public Example Behavior ---") + findings = public_example_errors(SKILL_ROOT / 'examples') + if findings: + for finding in findings: + err(f"Public example contract: {finding}") + else: + ok(f"Public examples: {len(PUBLIC_EXAMPLES)} behavioral contracts passed") + + def check_consistency(): """Check ARCHITECTURE.md stats and README file tree consistency.""" print("\n--- Consistency ---") @@ -424,19 +752,19 @@ def check_consistency(): print(f" Scripts: {actual_scripts}") print(f" SKILL.md lines: {actual_skill_lines}") - # Check for fixed-duration thresholds in reference files + # Check for numeric relationship-decision windows while preserving activity, + # stabilization, and historical-evidence durations/counts. threshold_found = [] for md_file in refs_dir.rglob('*.md'): content = md_file.read_text(encoding='utf-8', errors='ignore') - for pattern, desc in FIXED_THRESHOLD_PATTERNS: - matches = re.findall(pattern, content) - if matches: - # Check context: is it in an example being deconstructed, or a rule being stated? - threshold_found.append(f"{md_file.relative_to(SKILL_ROOT)}: '{matches[0]}' ({desc})") + for line_number, match, desc in find_relationship_thresholds(content): + threshold_found.append( + f"{md_file.relative_to(SKILL_ROOT)}:{line_number}: '{match}' ({desc})" + ) if threshold_found: - for t in threshold_found[:10]: - warn(f"Possible fixed threshold: {t}") + for t in threshold_found: + warn(f"Possible fixed relationship threshold: {t}") else: ok("No fixed thresholds detected") @@ -473,6 +801,7 @@ def main(): check_runtime_boundaries() check_markdown_links() check_placeholders() + check_public_examples() check_consistency() # Report @@ -492,4 +821,5 @@ def main(): if __name__ == '__main__': + configure_output_encoding() sys.exit(main())