Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Claude project hooks now run reliably when Claude launches them outside the
repository while keeping generated settings portable across clones. (#2408)
- `docs/src/content/docs/specs/openapm-v0.1.md` now requires project-scoped native
hooks to use portable project-directory anchors instead of absolute checkout paths,
with Claude POSIX and PowerShell command forms covered. (#2408)
- Release publication now excludes opt-in live ADO PAT tests and credentials; those tests fail closed in the Auth Acceptance workflow instead. (#2426)
- Release promotions now run marker-bounded lifecycle integration on macOS Intel
while retaining the full corpus on macOS ARM and Linux, preventing Intel
Expand Down
15 changes: 13 additions & 2 deletions CONFORMANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -1230,12 +1230,23 @@
"tests": [
"tests/spec_conformance/test_manifest_reqs.py::test_kiro_agent_tools_gate_fails_closed_before_adopt"
]
},
{
"conformance_class": "consumer",
"id": "req-tg-010",
"keyword": "MUST",
"section": "8.5.4",
"status": "active",
"test_count": 1,
"tests": [
"tests/spec_conformance/test_manifest_reqs.py::test_project_scoped_native_hook_command_is_portably_anchored"
]
}
],
"spec_version": "v0.1.1",
"summary_by_class": {
"consumer": {
"active": 78,
"active": 79,
"skipped": 1,
"unbound": 0,
"xfail": 0
Expand All @@ -1259,5 +1270,5 @@
"xfail": 0
}
},
"total_requirements": 108
"total_requirements": 109
}
3 changes: 2 additions & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac
| Class | Active | Skipped | Xfail | Unbound |
|-------|-------:|--------:|------:|--------:|
| Producer | 12 | 0 | 0 | 0 |
| Consumer | 78 | 1 | 0 | 0 |
| Consumer | 79 | 1 | 0 | 0 |
| Registry | 1 | 0 | 0 | 0 |
| Governance | 16 | 0 | 0 | 0 |

Expand Down Expand Up @@ -135,6 +135,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac
| [req-tg-007](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-007) | MUST | 8.5 | consumer | active | 1 |
| [req-tg-008](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-008) | MUST | 8.5.3 | consumer | active | 1 |
| [req-tg-009](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-009) | MUST | 8.5.1 | consumer | active | 1 |
| [req-tg-010](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-010) | MUST | 8.5.4 | consumer | active | 1 |

## Waivers

Expand Down
5 changes: 5 additions & 0 deletions docs/public/specs/manifests/openapm-v0.1.requirements.yml
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ requirements:
keyword: MUST
section: "8.5.1"
conformance_class: consumer
- id: req-tg-010
keyword: MUST
section: "8.5.4"
conformance_class: consumer
notes: "project-scoped native hooks use a portable project-directory environment anchor and reject shell-expansion path syntax"
- id: req-sc-001
keyword: MUST
section: "10.4"
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/integrations/ide-tool-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ mcp: in apm.yml -> per target: .mcp.json / settings.json / equivalent

Not every target supports every primitive type. When a primitive can't land on a target, APM emits a warning at install time. Skim [Targets matrix](../../reference/targets-matrix/) to set expectations before adding a primitive.

When APM rewrites a Claude project hook script path, it references
`CLAUDE_PROJECT_DIR` at runtime rather than an absolute checkout path. The
generated path remains portable across clones and works when Claude starts a
hook outside the project directory.

> **Deduplication**: When `.github/instructions/` already contains `.instructions.md` files (deployed by `apm install --target copilot`), `apm compile --target copilot` omits `AGENTS.md` entirely when its only content would be the duplicated instructions section. When `.claude/rules/` already contains `.md` files (deployed by `apm install --target claude`), `apm compile --target claude` omits the instructions section from `CLAUDE.md` for the same reason. The context file is still generated when it carries non-instruction content such as a constitution. See [Copilot deduplication](../../producer/compile/#copilot-deduplication) for details.

## Common workflows
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,11 @@ agent a procedure" fits a skill -- and reaches every harness.
- **Hook script path resolution.** `apm install -g` (user-scope)
rewrites `${PLUGIN_ROOT}` and relative `./` references to absolute
paths so Claude Code and Copilot CLI can execute scripts regardless
of the working directory. Project-scope `apm install` (no `-g`)
keeps `command` paths repo-relative so checked-in configs stay portable
across clones, contributors, and CI. Either way, if a referenced script
of the working directory. Project-scope `apm install` (no `-g`) keeps
non-Claude command paths repo-relative. Claude project hooks use
`CLAUDE_PROJECT_DIR` (or `$env:CLAUDE_PROJECT_DIR` for PowerShell) so
checked-in settings remain portable while hooks can run from outside the
project directory. Either way, if a referenced script
is missing at install time the installer emits a warning -- in
user-scope the unexpanded variable is rewritten to the absolute
source path so the hook fails loudly at runtime; in project-scope
Expand Down
27 changes: 24 additions & 3 deletions docs/src/content/docs/specs/openapm-v0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ between the companion corpus and the implementation.

### 1.3 Document conventions

- OpenAPM v0.1 carries **108 normative statements** indexed in
- OpenAPM v0.1 carries **109 normative statements** indexed in
[Appendix C](#appendix-c-index-of-normative-statements).
- All on-disk files defined by this specification are **YAML 1.2**
parsed under the safe subset defined in
Expand Down Expand Up @@ -2414,6 +2414,25 @@ hook entries and their ownership record MUST be reconciled under
[req-lk-021](#req-lk-021), while entries without the consumer's own
ownership attribution remain preserved.

#### 8.5.4 Project-scoped native hook execution

<a id="req-tg-010"></a>
**[req-tg-010]** A conforming **consumer** implementation that deploys a
project-scoped hook into a target-native configuration whose hook command may
be launched with a working directory outside the consumer project MUST anchor
the generated command to the consumer project through that target's portable
project-directory environment variable. The command MUST execute successfully
when that variable identifies the consumer project, MUST preserve the hook's
relative path beneath that project, and MUST NOT embed an absolute consumer
checkout path. For Claude project hooks, such a consumer MUST reject a
hook path containing a dollar sign or backtick, because either character can
cause the target shell to reinterpret a path component.

> **Editorial note.** For Claude project hooks, the portable variable is
> `CLAUDE_PROJECT_DIR` in POSIX commands and `$env:CLAUDE_PROJECT_DIR` in
> PowerShell commands. This requirement permits target-specific command syntax;
> it does not prescribe a shell for other targets.

### 8.6 Per-target primitive support (informational)

The matrix of which primitive types each target supports is
Expand Down Expand Up @@ -2964,7 +2983,7 @@ conformance statement identifying:
[req-tg-004](#req-tg-004), [req-tg-005](#req-tg-005),
[req-tg-006](#req-tg-006), [req-tg-007](#req-tg-007),
[req-tg-008](#req-tg-008), [req-tg-009](#req-tg-009),
[req-sc-001](#req-sc-001),
[req-tg-010](#req-tg-010), [req-sc-001](#req-sc-001),
[req-sc-002](#req-sc-002), [req-sc-003](#req-sc-003),
[req-sc-004](#req-sc-004), [req-sc-005](#req-sc-005),
[req-sc-006](#req-sc-006), [req-sc-007](#req-sc-007),
Expand Down Expand Up @@ -3393,6 +3412,7 @@ renumbering of conformance classes.
| [req-tg-007](#req-tg-007) | MUST | 8.5 | consumer |
| [req-tg-008](#req-tg-008) | MUST | 8.5.3 | consumer |
| [req-tg-009](#req-tg-009) | MUST | 8.5.1 | consumer |
| [req-tg-010](#req-tg-010) | MUST | 8.5.4 | consumer |
| [req-sc-001](#req-sc-001) | MUST | 10.4 | consumer |
| [req-sc-002](#req-sc-002) | MUST | 10.9 | consumer |
| [req-sc-003](#req-sc-003) | MUST | 10.3 | consumer |
Expand All @@ -3410,7 +3430,7 @@ renumbering of conformance classes.
| [req-cf-001](#req-cf-001) | MUST | 12.5 | consumer |
| [req-cf-002](#req-cf-002) | MUST | 12.3 | consumer |

**Total normative statements: 108** (103 MUST, 5 SHOULD).
**Total normative statements: 109** (104 MUST, 5 SHOULD).

---

Expand Down Expand Up @@ -3444,6 +3464,7 @@ renumbering of conformance classes.
| 0.1.22 | 2026-07-31 | Spec-citation fold for deterministic configured-host credential isolation (closes #2338). Added [req-sc-013] (Section 10.3, consumer MUST): a consumer selects one effective host class before credential resolution, applies documented deterministic precedence when configuration signals overlap, exposes only credentials belonging to the selected class to requests and child processes, and preserves an explicit non-default port in both transport and credential scope. Clarified [req-sc-005] so this configured override is not prohibited by its default host-class collapse rule. Section 1.3, Section 10.11, Section 11.3.2 Consumer enumeration, and Appendix C updated. Statement count: 105 -> 106 (101 MUST, 5 SHOULD). |
| 0.1.23 | 2026-07-31 | Spec-citation fold for case-preserving dependency materialization (closes #2347). Added [req-lk-022] (Section 5.2, consumer MUST): a consumer that case-folds repository identity but retains different source spelling records `materialization_repo_url`, validates it maps to the same canonical identity, excludes it from identity/cache/sort/trust decisions, preserves exact virtual-path casing, and either transactionally migrates one stale case variant or fails closed without deleting colliding paths. Defined rollback semantics for case-only rename and preserved interrupted recovery state. Added the field to the lockfile schema and conformance fixture, plus migration and collision conformance oracles. Hardened lockfile schema: `repo_url` now carries `minLength: 1` to match the prose requirement that git-sourced entries provide a non-empty canonical identifier ([req-lk-003](#req-lk-003)). Section 5.7, Section 10.11, Section 11.3.2, and Appendix C updated. Statement count: 106 -> 107 (102 MUST, 5 SHOULD). |
| 0.1.24 | 2026-08-03 | Spec-citation fold for fail-closed Kiro agent vocabulary gate (closes #2089 Mode-B silent-extension gate). Added [req-tg-009] (Section 8.5.1, consumer MUST): a consumer deploying an agent primitive into a target with a fixed, enumerable capability vocabulary MUST fail closed -- writing zero bytes and emitting an actionable diagnostic -- if any source-declared tool falls outside the approved set; the gate fires per agent independently and does not block vocabulary-conformant sibling agents; the gate applies only to targets included in the effective intersection under [req-tg-008]; content-identity fast-paths are not exempt. Added editorial note naming the Target Registry companion as the vocabulary authority and mandating version-pinning for conformance testing. Section 8.7, Section 11.3.2 Consumer enumeration, and Appendix C updated. Statement count: 107 -> 108 (103 MUST, 5 SHOULD). |
| 0.1.25 | 2026-08-03 | Spec-citation fold for portable project-scoped Claude hooks (closes #2408 Mode-B silent-extension gate). Added [req-tg-010] (Section 8.5.4, consumer MUST): a project-scoped native hook that may launch outside the consumer project anchors its generated command through the target portable project-directory environment variable, preserves the relative hook path, executes successfully when the variable identifies the consumer project, and never embeds an absolute checkout path; shell-expansion path syntax is rejected. Claude uses `CLAUDE_PROJECT_DIR` in POSIX and `$env:CLAUDE_PROJECT_DIR` in PowerShell. Section 8.7, Section 11.3.2 Consumer enumeration, and Appendix C updated. Statement count: 108 -> 109 (104 MUST, 5 SHOULD). |

Errata (none at publication).

Expand Down
13 changes: 13 additions & 0 deletions scripts/lint-architecture-boundaries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,19 @@ if [ "$hook_scope_owner_count" -ne 1 ] \
[ -n "$hook_scope_duplicate_hits" ] && echo "$hook_scope_duplicate_hits"
violations=$((violations + 1))
fi
hook_project_dir_owner_count=$(grep -Fc '"CLAUDE_PROJECT_DIR"' "$hook_file" || true)
hook_project_dir_duplicate_hits=$(
grep -REn --include='*.py' '"CLAUDE_PROJECT_DIR"' src/apm_cli \
| grep -v "^${hook_file}:" \
| grep -v 'architecture-authority-exempt:' \
|| true
)
if [ "$hook_project_dir_owner_count" -ne 1 ] \
|| [ -n "$hook_project_dir_duplicate_hits" ]; then
echo "[x] Claude project hook paths must be owned by HookIntegrator"
[ -n "$hook_project_dir_duplicate_hits" ] && echo "$hook_project_dir_duplicate_hits"
violations=$((violations + 1))
fi
hook_event_map_owner_count=$(grep -Ec \
'^_HOOK_EVENT_MAP[[:space:]]*[:=]' "$hook_file" || true)
hook_event_map_duplicate_hits=$(
Expand Down
91 changes: 52 additions & 39 deletions src/apm_cli/integration/hook_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,32 @@ def _parse_hook_json(self, hook_file: Path) -> dict | None:
except (json.JSONDecodeError, OSError):
return None

@staticmethod
def _project_scoped_command_path(
command: str,
target: str,
target_rel: str,
deploy_root: Path | None,
source_key: str | None = None,
path_is_quoted: bool = False,
) -> str:
"""Return a target-native script reference without sacrificing portability."""
if deploy_root is not None:
return str((deploy_root / target_rel).resolve())
if target != "claude":
return target_rel

if "$" in target_rel or "`" in target_rel:
raise ValueError("Claude project hook paths cannot contain shell expansion characters")

project_dir = "CLAUDE_PROJECT_DIR"
if source_key == "powershell" or re.match(
r"\s*(?:powershell|pwsh)(?:\.exe)?(?:\s|$)", command, re.IGNORECASE
):
return f"$env:{project_dir}/{target_rel}"
path = f"${{{project_dir}}}/{target_rel}"
return path if path_is_quoted else f'"{path}"'

def _rewrite_command_for_target(
self,
command: str,
Expand All @@ -597,30 +623,9 @@ def _rewrite_command_for_target(
hook_file_dir: Path | None = None,
root_dir: str | None = None,
deploy_root: Path | None = None,
source_key: str | None = None,
) -> tuple[str, list[tuple[Path, str]]]:
"""Rewrite a hook command to use installed script paths.

Handles:
- ${CLAUDE_PLUGIN_ROOT}/path references (resolved from package root)
- ./path relative references (resolved from hook file's parent directory)
- Windows backslash variants of both (.\\ and ${CLAUDE_PLUGIN_ROOT}\\)

Args:
command: Original command string
package_path: Root path of the source package
package_name: Name used for the scripts subdirectory
target: "vscode" or "claude"
hook_file_dir: Directory containing the hook JSON file (for ./path resolution)
root_dir: Override root directory (e.g. ".copilot" for user scope)
deploy_root: Absolute root of the deployment directory. When provided,
rewritten script paths are resolved to absolute paths under this
root so the target (e.g. Claude Code) can execute them regardless
of the working directory. When *None*, rewritten paths stay
relative (backward-compatible behaviour).

Returns:
Tuple of (rewritten_command, list of (source_file, relative_target_path))
"""
"""Rewrite plugin-root and relative script references for a target."""
scripts_to_copy = []
new_command = command

Expand All @@ -643,9 +648,7 @@ def _rewrite_command_for_target(
base_root = root_dir or ".claude"
scripts_base = f"{base_root}/hooks/{package_name}"

# Handle plugin root variable references (always relative to package root)
# Match both forward-slash and backslash separators (Windows hook JSON
# may use backslashes: ${CLAUDE_PLUGIN_ROOT}\scripts\scan.ps1)
# Match plugin-root references with forward or Windows-style separators.
plugin_root_pattern = (
r"\$\{(?:CLAUDE_PLUGIN_ROOT|CURSOR_PLUGIN_ROOT|KIRO_PLUGIN_ROOT|PLUGIN_ROOT)\}"
r"([\\/][^\s\"']+)"
Expand All @@ -663,10 +666,16 @@ def _rewrite_command_for_target(
if source_file.exists() and source_file.is_file():
target_rel = f"{scripts_base}/{rel_path}"
scripts_to_copy.append((source_file, target_rel))
resolved_cmd = (
str((deploy_root / target_rel).resolve())
if deploy_root is not None
else target_rel
resolved_cmd = self._project_scoped_command_path(
command,
target,
target_rel,
deploy_root,
source_key,
match.start() > 0
and match.end() < len(command)
and command[match.start() - 1] in "\"'"
and command[match.end()] == command[match.start() - 1],
)
new_command = new_command.replace(full_var, resolved_cmd)
else:
Expand All @@ -681,11 +690,7 @@ def _rewrite_command_for_target(
if deploy_root is not None:
new_command = new_command.replace(full_var, str(source_file))

# Handle relative ./path and .\path references (safe to run after
# ${CLAUDE_PLUGIN_ROOT} substitution since replacements produce paths
# like ".github/..." not "./" or ".\")
# Match both forward-slash and backslash separators (Windows hook JSON
# may use backslashes: .\scripts\scan.ps1)
# Replacements above cannot match this relative-path pattern.
rel_pattern = r"(\.[\\/][^\s\"']+)"
for match in re.finditer(rel_pattern, new_command):
rel_ref = match.group(1)
Expand All @@ -698,10 +703,16 @@ def _rewrite_command_for_target(
if source_file.exists() and source_file.is_file():
target_rel = f"{scripts_base}/{rel_path}"
scripts_to_copy.append((source_file, target_rel))
resolved_cmd = (
str((deploy_root / target_rel).resolve())
if deploy_root is not None
else target_rel
resolved_cmd = self._project_scoped_command_path(
command,
target,
target_rel,
deploy_root,
source_key,
match.start() > 0
and match.end() < len(command)
and command[match.start() - 1] in "\"'"
and command[match.end()] == command[match.start() - 1],
)
new_command = new_command.replace(rel_ref, resolved_cmd)
else:
Expand Down Expand Up @@ -766,6 +777,7 @@ def _rewrite_hooks_data(
hook_file_dir=hook_file_dir,
root_dir=root_dir,
deploy_root=deploy_root,
source_key=key,
)
if scripts:
_log.debug(
Expand Down Expand Up @@ -793,6 +805,7 @@ def _rewrite_hooks_data(
hook_file_dir=hook_file_dir,
root_dir=root_dir,
deploy_root=deploy_root,
source_key=key if key != "command" else hook.get("shell"),
)
if scripts:
_log.debug(
Expand Down
Loading
Loading