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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr
| CC-464 | 🟢 someday | `pmctl ticket draft --from <notes>`:隨手筆記→結構化 backlog 票草稿;依賴 CC-286(prefix-generic next-id,⏸ deferred 尚未排程);review-first 邊界獨立設計,CC-054 僅供鬆散參照非直接前例(2026-07-07 openyida 跨專案分析) | ux/process | 2026-07-07 | — | P3 | — |
| CC-486 | ⏸ deferred | direct-impact test planner mapping 提前退出:changed path 含 `agents/*.md`/`commands/*.md` 時 `map_path` 呼叫未註冊的 `lint-frontmatter`,`add_suite` 回傳 1 並在 `set -e` 下無輸出終止,導致 `run-tests.sh --base ... --list` exit 1 | ops/test | 2026-07-13 | feedback:2026-07-13 | P2 | hygiene |
| CC-489 | 🔵 active | `scripts/` domain ownership 重整:先收斂 module ABI、變數/config ownership 與 side-effect boundary,再將 host/runtime/ops/test entrypoints 以 manifest/registry 與相容 shim 分批遷移 | arch | 2026-07-14 | feedback:2026-07-15 | P2 | design |
| CC-490 | 🔵 active | project-scoped explicit memory config:取代全域單值 `dispatch.memory_dir`,避免多 repo 靜默共用 pm-dispatch canonical store | arch/memory | 2026-07-14 | feedback:2026-07-14 | P1 | design |
| CC-490 | ✅ done | project-scoped explicit memory config:取代全域單值 `dispatch.memory_dir`,避免多 repo 靜默共用 pm-dispatch canonical store | arch/memory | 2026-07-14 | pr:#406 | P1 | design |
| CC-491 | 🔵 active | PR-gate pre-flight 機械式 evidence contract:傳遞 command、selected suites、逐項結果與 tree fingerprint,讓 reviewer reuse 已驗證結果並禁止無條件重跑 | ops/gate | 2026-07-14 | feedback:2026-07-14 | P1 | design |
| CC-493 | 🟢 someday | Prompt→Skill→Command→Harness 升級規則文件化:可測試的分類判準(何時停在 prompt、何時升為 skill、何時做成 command、何時需要 harness-level hook/guard/state),並盤點 `commands/`/`skills/`/`agents/` 現況對照分類(2026-07-15 CC-489 三方 multi-model synthesis) | process/docs | 2026-07-15 | feedback:2026-07-15 | P2 | design |
| CC-494 | 🟢 someday | design: executor 局部設計裁量權 envelope——在 dispatch brief / executor contract 定義「可自行處理的局部設計」與「必須 halt 回報 PM」的邊界(例如新增 schema 欄位 `design_latitude`/`architectural_conflicts`);三方 multi-model synthesis 2:1 分歧(codex/fable 認為現行邊界過度僵硬需要新機制,opencode 認為現行 `isolation_level`/executor 欄位已足夠彈性),本票僅追蹤決策、不預設結論(2026-07-15) | schema/process | 2026-07-15 | feedback:2026-07-15 | P3 | design |
Expand Down Expand Up @@ -385,7 +385,7 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed

---

## CC-490 — project-scoped explicit memory config,避免跨 repo canonical bleed 🔵 active
## CC-490 — project-scoped explicit memory config,避免跨 repo canonical bleed ✅ 2026-07-15

**Problem**: `~/.pm-dispatch/config` 的 `dispatch.memory_dir` 是全域單值,但 resolver 對每個 repo 都無條件套用。CC-488 gate R1 後的 live read-only probe 已確認:pm-dispatch、JapanJob、qa-testing-rules 雖有不同 stable `project_key`,三者目前都回報 `resolution_source=config` 並解析到 pm-dispatch 的 canonical memory dir。任何其他 repo 的 canonical append 因此可能成功但寫入錯誤專案,屬靜默 cross-project data bleed;同一問題亦使本機 fixture 在未隔離 config 時誤寫 live store。

Expand All @@ -399,10 +399,12 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed

**Immediate safety note**: 在本票落地前,`dispatch.memory_dir` 只能視為 single-repo/single-purpose config;多 repo 操作不得把它當成安全的 machine-wide default。測試一律用 `PM_DISPATCH_CONFIG_FILE` 隔離,不得讀取 operator live config。

**Implementation (2026-07-14, in progress)**: config schema 改為 `memory.projects.<stable-project-key>.dir`;`pmctl memory config set|migrate|lint` 提供原子管理、legacy-global 診斷與可重複 migration。strict resolver 對舊 global key 回報 `config-legacy-global` / exit 3,matched invalid path 維持 fail closed,unmatched repo 回到自身 legacy 或 unavailable。isolated regression 已覆蓋零 cross-project append;live config 已遷移為 pm-dispatch scoped mapping,JapanJob read-only probe 回到自身 legacy memory,config lint 0 issues。full-suite 與 gate 完成前本票維持 active
**Implementation (2026-07-15)**: config schema 已改為 `memory.projects.<stable-project-key>.dir`;`pmctl memory config set|migrate|lint` 提供原子管理、legacy-global 診斷與可重複 migration。strict resolver 對舊 global key 回報 `config-legacy-global` / exit 3,matched invalid path 維持 fail closed,unmatched repo 回到自身 legacy 或 unavailable。完善稽核另補上原實作未封閉的 compatibility fallback:`memory dir`、doctor、shard、rebuild-summary、direct `context --source memory` 與 installer/migrator discovery 現在共用 explicit-selection validity helper;invalid matched/env target 不得讀寫 legacy store,doctor 以 `resolution_issues` 回報來源與原因。isolated regression 已擴為三個 project key、project-scoped 四 host continuity、maintenance/context 零 fallback write;live read-only probe 證明 pm-dispatch scoped config,JapanJob 與 qa-testing-rules 各回自身 legacy,config lint 0 issues。Claude standard gate `gate-20260715-032942-90cd0b` 使用明確 `--test-cmd` 後 GO(critic approve、qa-tester pass、architecture-reviewer approve);gate 後 authoritative full suite 79 passed、0 failed、0 skipped

**Acceptance**: 三 repo resolve 的 project key 與 memory dir 對應正確;跨 host 同 repo continuity 不退化;invalid matched config fail closed;unmatched repo 不使用 pm-dispatch memory;doctor/config diagnostics、memory/pm/guard regressions與 live migration evidence 全綠。

**See**: pr:#406

**Source**: CC-488 Claude full-tier gate R1 risk/QA findings;2026-07-14 live probe confirmed JapanJob (`01a9ed...`) and qa-testing-rules (`100334...`) both resolved to pm-dispatch memory through the global config value.

---
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Versions follow [Semantic Versioning](https://semver.org/).

### Added

- **Project-scoped canonical memory config (CC-490).** Replaces the unsafe machine-wide `dispatch.memory_dir` selector with `memory.projects.<stable-project-key>.dir`. `pmctl memory config set|migrate|lint` manages mappings atomically, diagnoses deprecated global config, and keeps unmatched repositories on their own legacy discovery or unavailable path. Strict resolution fails closed for invalid matched entries and legacy-global config; regression coverage proves an unmatched append cannot mutate another project's canonical store.
- **Project-scoped canonical memory config (CC-490).** Replaces the unsafe machine-wide `dispatch.memory_dir` selector with `memory.projects.<stable-project-key>.dir`. `pmctl memory config set|migrate|lint` manages mappings atomically, diagnoses deprecated global config, and keeps unmatched repositories on their own legacy discovery or unavailable path. Invalid explicit selections now fail closed across resolve, context indexing, doctor, and mutating maintenance commands; regression coverage proves an unmatched append or invalid maintenance operation cannot mutate another project's canonical store.

- **Memory substrate cross-tool location seam (CC-412, PR#352).** `find_memory_dir` now honors an explicit override with precedence `PM_MEMORY_DIR` env > `dispatch.memory_dir` config > `CLAUDE_CONFIG_DIR` convention — behavior is byte-identical to before when neither override is set. The injection layering is now documented in `docs/memory-system.md`: the portable core is the `pmctl context --source memory` retrieval API; injection is a per-tool adapter concern (Claude keeps its existing hook; codex/opencode/future hosts call the retrieval API directly).

Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/script-variable-consumers.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,15 @@ PM_CFG_* PM_CFG_LEGACY_MEMORY_DIR scripts/lib/pmctl-memory-config.sh production
PM_CFG_* PM_CFG_LEGACY_MEMORY_DIR_INVALID scripts/lib/pmctl-config.sh production
PM_CFG_* PM_CFG_LIFECYCLE scripts/lib/pmctl-config.sh production
PM_CFG_* PM_CFG_LIFECYCLE scripts/lib/pmctl-dispatch.sh production
PM_CFG_* PM_CFG_MEMORY_CONFIG_STATUS scripts/lib/memory.sh production
PM_CFG_* PM_CFG_MEMORY_CONFIG_STATUS scripts/lib/pmctl-config.sh production
PM_CFG_* PM_CFG_MEMORY_CONFIG_STATUS scripts/lib/pmctl-memory-config.sh production
PM_CFG_* PM_CFG_MEMORY_CONFIG_STATUS scripts/lib/pmctl-memory.sh production
PM_CFG_* PM_CFG_MEMORY_DIR scripts/lib/memory.sh production
PM_CFG_* PM_CFG_MEMORY_DIR scripts/lib/pmctl-config.sh production
PM_CFG_* PM_CFG_MEMORY_DIR scripts/lib/pmctl-memory-config.sh production
PM_CFG_* PM_CFG_MEMORY_DIR scripts/lib/pmctl-memory.sh production
PM_CFG_* PM_CFG_MEMORY_DIR_INVALID scripts/lib/memory.sh production
PM_CFG_* PM_CFG_MEMORY_DIR_INVALID scripts/lib/pmctl-config.sh production
PM_CFG_* PM_CFG_MEMORY_DIR_INVALID scripts/lib/pmctl-memory.sh production
PM_CFG_* PM_CFG_TIMEOUT adapters/claude/dispatch.sh production
Expand Down
12 changes: 9 additions & 3 deletions docs/memory-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ Report fields (ordered): `memory_dir`, `entry_count`, `memory_bytes`,
`topics`/`priority`/`status`/`updated_at`/`repo_refs`; `repo_refs` may be `[]`
but the key must exist), `issues_count`. `--json` emits a single object carrying
`schema_version: 1`. Exit codes: `0` healthy, `1` issues found, `2` usage error.
When explicit canonical resolution is invalid, the report remains read-only and
returns one additive `resolution_issues` entry with the rejected source and
reason; it never inspects a legacy fallback as though that were the selected
project store.

`cards_missing_fields` lists any not-yet-migrated card still lacking a required
field — useful for spot-checking coverage even though write-time enforcement is
Expand Down Expand Up @@ -205,9 +209,11 @@ after success is a no-op. Tests must still isolate operator config with
`PM_DISPATCH_CONFIG_FILE`; they must never assume `~/.pm-dispatch/config` is
absent.

An override is only honored when the target directory already exists; an
unset or nonexistent override falls through to the next tier, so existing
installs see byte-identical resolution with no environment changes.
An unset override falls through to the next tier. Once an environment override
or matching project-scoped config entry is present, however, an invalid or
missing target fails closed for `pmctl memory`, direct memory-context indexing,
and installer/migrator discovery. Mutating maintenance commands such as `shard`
and `rebuild-summary` therefore cannot redirect writes into a legacy store.

At a **host-switch boundary**, use the stricter diagnostic contract instead
of relying on that compatibility fallback:
Expand Down
1 change: 1 addition & 0 deletions scripts/lib/memory-dir.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ find_memory_dir() {
local cwd="$1" project_key=""
declare -F pm_config_project_key >/dev/null 2>&1 && project_key="$(pm_config_project_key "$cwd" 2>/dev/null || true)"
declare -F pm_config_load >/dev/null 2>&1 && pm_config_load "$project_key"
_pm_memory_explicit_selection_invalid && return 1
local override
if override="$(_pm_memory_dir_override)"; then
printf '%s' "$override"; return 0
Expand Down
19 changes: 17 additions & 2 deletions scripts/lib/memory.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ encode_path() {
# over PM_CFG_MEMORY_DIR (~/.pm-dispatch/config `dispatch.memory_dir`, only
# populated when a caller has already run pm_config_load — hooks never do,
# so this stays a plain variable read with zero file I/O on the hook path).
# Silently falls through (does not use the override) when the directory does
# not exist, matching the existing CLAUDE_ROUTING_LOG_DIR convention.
# This low-level selector ignores unavailable targets. Canonical CLI callers
# first use _pm_memory_explicit_selection_invalid so an explicit selection
# cannot fall through; legacy-only callers retain their discovery convention.
_pm_memory_dir_override() {
local d="${PM_MEMORY_DIR:-}"
if [[ -n "$d" && -d "$d" ]]; then printf '%s' "$d"; return 0; fi
Expand All @@ -23,6 +24,20 @@ _pm_memory_dir_override() {
return 1
}

# Return success when an explicitly selected canonical-memory target is invalid.
# Callers that have loaded project config use this before compatibility discovery
# so one shared rule prevents fallback into another store.
_pm_memory_explicit_selection_invalid() {
if [[ -n "${PM_MEMORY_DIR:-}" && ( "${PM_MEMORY_DIR}" != /* || ! -d "${PM_MEMORY_DIR}" ) ]]; then
return 0
fi
[[ "${PM_CFG_MEMORY_DIR_INVALID:-0}" -eq 0 ]] || return 0
if [[ "${PM_CFG_MEMORY_CONFIG_STATUS:-none}" == "matched" && ! -d "${PM_CFG_MEMORY_DIR:-}" ]]; then
return 0
fi
return 1
}

find_memory_dir() {
local override
if override="$(_pm_memory_dir_override)"; then
Expand Down
4 changes: 4 additions & 0 deletions scripts/lib/pmctl-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ _ctx_resolve_memory_dir() {
declare -F find_memory_dir >/dev/null 2>&1 || return 1
declare -F pm_config_project_key >/dev/null 2>&1 && project_key="$(pm_config_project_key "$cwd" 2>/dev/null || true)"
declare -F pm_config_load >/dev/null 2>&1 && pm_config_load "$project_key"
# Never let direct context commands turn an invalid explicit selector into a
# write beside another repository's legacy memory. Host entrypoints already
# use the strict resolver; this closes the standalone context path as well.
_pm_memory_explicit_selection_invalid && return 3
find_memory_dir "$cwd" 2>/dev/null
}

Expand Down
57 changes: 47 additions & 10 deletions scripts/lib/pmctl-memory.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ pmctl_memory_dir() {
local cwd="${1:-$PWD}"
local mem_dir
_pmctl_memory_load_config_override "$cwd"
if _pm_memory_explicit_selection_invalid; then
printf 'pmctl memory dir: explicit canonical memory selection is invalid\n' >&2
return 3
fi
mem_dir="$(find_memory_dir "$cwd")" || return 1
printf '%s\n' "$mem_dir"
}
Expand Down Expand Up @@ -107,6 +111,24 @@ _pmctl_memory_emit_resolution() {
fi
}

# Resolve a canonical memory directory for mutating maintenance commands. These
# commands must not use find_memory_dir's compatibility fallback: an invalid
# explicit selection could otherwise redirect writes into a legacy store.
_pmctl_memory_require_resolved_dir() {
local repo_root="$1" operation="$2" resolution rc=0 reason
resolution="$(pmctl_memory_resolve --repo-root "$repo_root" --allow-non-git --json)" || rc=$?
if [[ "$rc" -ne 0 ]]; then
if [[ "$rc" -eq 1 ]]; then
printf 'pmctl memory %s: no memory directory found\n' "$operation" >&2
return 1
fi
reason="$(jq -r '.reason // .status // "unavailable"' <<<"$resolution" 2>/dev/null || printf 'unavailable')"
printf 'pmctl memory %s: canonical memory resolution failed: %s\n' "$operation" "$reason" >&2
return "$rc"
fi
jq -er '.memory_dir // empty' <<<"$resolution"
}

# Strict, diagnostic resolver used at cross-host boundaries. Unlike the legacy
# find_memory_dir API, an explicit but unavailable override is an error: host
# switching must never appear successful while silently selecting other memory.
Expand Down Expand Up @@ -495,10 +517,25 @@ pmctl_memory_doctor() {
esac
done

local mem_dir=""
_pmctl_memory_load_config_override "$repo_root"
if ! mem_dir="$(find_memory_dir "$repo_root")"; then
mem_dir=""
local mem_dir="" resolution="" resolution_rc=0 resolution_reason="" resolution_source="none"
resolution="$(pmctl_memory_resolve --repo-root "$repo_root" --allow-non-git --json)" || resolution_rc=$?
if [[ "$resolution_rc" -eq 0 ]]; then
mem_dir="$(jq -r '.memory_dir // empty' <<<"$resolution")"
elif [[ "$resolution_rc" -eq 3 ]]; then
resolution_reason="$(jq -r '.reason // "invalid explicit canonical memory configuration"' <<<"$resolution")"
resolution_source="$(jq -r '.resolution_source // "none"' <<<"$resolution")"
if [[ "$json" -eq 1 ]]; then
jq -cn --arg source "$resolution_source" --arg reason "$resolution_reason" \
'{schema_version:1,memory_dir:"",entry_count:0,memory_bytes:0,episodes_bytes:0,shard_count:0,dead_links:[],orphan_cards:[],duplicate_hooks:[],stale_repo_refs:[],cards_missing_fields:[],resolution_issues:[{source:$source,reason:$reason}],issues_count:1}'
else
printf 'memory_dir: (invalid explicit configuration)\n'
printf 'resolution_issues:\n - %s: %s\n' "$resolution_source" "$resolution_reason"
printf 'issues_count: 1\n'
fi
return 1
elif [[ "$resolution_rc" -eq 2 ]]; then
printf 'pmctl memory doctor: unable to resolve repository root\n' >&2
return 2
fi

# No memory dir → nothing to check; report an empty, healthy result.
Expand Down Expand Up @@ -758,9 +795,9 @@ pmctl_memory_shard() {
esac
done

local mem_dir=""
_pmctl_memory_load_config_override "$repo_root"
mem_dir="$(find_memory_dir "$repo_root")" || { printf 'pmctl memory shard: no memory directory found\n' >&2; return 1; }
local mem_dir="" resolve_rc=0
mem_dir="$(_pmctl_memory_require_resolved_dir "$repo_root" shard)" || resolve_rc=$?
[[ "$resolve_rc" -eq 0 ]] || return "$resolve_rc"

local ep="$mem_dir/episodes.jsonl"
[[ -f "$ep" ]] || { printf 'pmctl memory shard: no episodes.jsonl found\n'; return 0; }
Expand Down Expand Up @@ -822,9 +859,9 @@ pmctl_memory_rebuild_summary() {
esac
done

local mem_dir=""
_pmctl_memory_load_config_override "$repo_root"
mem_dir="$(find_memory_dir "$repo_root")" || { printf 'pmctl memory rebuild-summary: no memory directory found\n' >&2; return 1; }
local mem_dir="" resolve_rc=0
mem_dir="$(_pmctl_memory_require_resolved_dir "$repo_root" rebuild-summary)" || resolve_rc=$?
[[ "$resolve_rc" -eq 0 ]] || return "$resolve_rc"

local ep="$mem_dir/episodes.jsonl"
[[ -f "$ep" ]] || { printf 'pmctl memory rebuild-summary: no episodes.jsonl found\n'; return 0; }
Expand Down
35 changes: 35 additions & 0 deletions scripts/test-doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ DOCTOR="$REPO_ROOT/scripts/doctor.sh"
# shellcheck source=scripts/lib/test-harness.sh
. "$SCRIPT_DIR/lib/test-harness.sh"
th_init "$@"
# shellcheck source=scripts/lib/test-memory-config-fixtures.sh
. "$SCRIPT_DIR/lib/test-memory-config-fixtures.sh"

# check_codex/check_claude FAIL when an executor CLI is present but
# unauthenticated. The many stub-claude/codex tests below model a HEALTHY
Expand Down Expand Up @@ -551,6 +553,38 @@ case_doctor_repo_from_different_cwd() {
fi
}

case_doctor_invalid_project_memory_does_not_fallback() {
# Verifies that an unavailable project-scoped memory selection does not let
# doctor report a populated legacy Claude memory directory as healthy.
#
# Steps:
# 1. Populate the legacy memory directory for REPO_ROOT.
# 2. Configure REPO_ROOT to use a missing project-scoped memory directory.
# 3. Run doctor and assert memory-dir warns without reporting the legacy path.
local name="doctor-invalid-project-memory-does-not-fallback"
should_run "$name" || return 0
local home="$tmp_root/home-invalid-project-memory" config="$tmp_root/invalid-project-memory.config"
local missing="$tmp_root/missing-project-memory" legacy out status=0 path encoded
write_full_settings "$home"
write_manifest "$home"
# shellcheck source=scripts/lib/memory.sh
. "$REPO_ROOT/scripts/lib/memory.sh"
encoded="$(encode_path "$REPO_ROOT")"
legacy="$home/.claude/projects/$encoded/memory"
mkdir -p "$legacy"
write_project_memory_config "$config" "$REPO_ROOT" "$missing"
path="$(make_stub_bin "$tmp_root/bin-invalid-project-memory" claude codex)"

out="$(PM_DISPATCH_CONFIG_FILE="$config" HOME="$home" CLAUDE_CONFIG_DIR="$home/.claude" PATH="$path" \
bash "$DOCTOR" --no-color --repo "$REPO_ROOT" 2>&1)" || status=$?
if [[ "$out" == *"[WARN] no memory directory for current path"* \
&& "$out" != *"memory directory exists: $legacy"* ]]; then
pass "$name"
else
fail "$name" "expected fail-closed memory-dir warning; status=$status out=$out"
fi
}

case_doctor_frontmatter_lint_ok() {
# Verifies that the frontmatter-lint check passes on the real repo's agents/.
#
Expand Down Expand Up @@ -2082,6 +2116,7 @@ case_doctor_quiet_no_ok_lines
case_doctor_jq_missing_exits_1
case_doctor_warn_only_exits_0
case_doctor_repo_from_different_cwd
case_doctor_invalid_project_memory_does_not_fallback
case_doctor_frontmatter_lint_ok
case_doctor_help_exits_0
case_doctor_unknown_flag
Expand Down
Loading