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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ Hooks run with working directory = the **leader session cwd** and receive contex
- `PI_TEAMS_MEMBER`
- `PI_TEAMS_TASK_ID`, `PI_TEAMS_TASK_SUBJECT`, `PI_TEAMS_TASK_OWNER`, `PI_TEAMS_TASK_STATUS`

See [`docs/hook-contract.md`](docs/hook-contract.md) for the full versioned contract, JSON schema, and compatibility policy.
See [`docs/hook-contract.md`](docs/hook-contract.md) for the full versioned contract, JSON schema, compatibility policy, and runtime version helpers (`checkHookContractVersion`, `parseHookContextSafe`).

Hook policy can be controlled by agents at runtime via `teams` tool actions:

Expand Down
38 changes: 38 additions & 0 deletions docs/hook-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ if (ctx.event === "task_failed") {
// ✅ Good: check the version for breaking changes
const version = parseInt(process.env.PI_TEAMS_HOOK_CONTEXT_VERSION, 10);
if (version > 1) {
// Version is newer than what we were written for.
// Additive fields are safe to ignore — only bail on major mismatch.
console.warn(`Hook context version ${version} is newer than expected (1). Proceeding with best-effort parsing.`);
}
if (version < 1) {
console.error(`Unsupported hook contract version: ${version}`);
process.exit(1);
}
Expand All @@ -148,6 +153,39 @@ assert(Object.keys(ctx).length === 5); // breaks when fields are added
const subject = ctx.task.subject; // crashes when task is null
```

### Runtime version helpers

For hooks written in TypeScript/JavaScript that import from `pi-agent-teams`:

```ts
import {
HOOK_CONTRACT_VERSION,
HOOK_CONTRACT_VERSION_MIN,
checkHookContractVersion,
parseHookContextSafe,
} from "@tmustier/pi-agent-teams/extensions/teams/hooks.js";

// checkHookContractVersion returns { status, message }
// status: "compatible" | "newer_minor" | "unsupported"
const check = checkHookContractVersion(2);
if (check.status === "unsupported") process.exit(1);
if (check.status === "newer_minor") console.warn(check.message);

// parseHookContextSafe combines JSON parse + version check
const result = parseHookContextSafe(process.env.PI_TEAMS_HOOK_CONTEXT_JSON);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
const { payload, versionCheck } = result;
// payload is typed as HookContextPayload; versionCheck.status tells you
// whether additive fields may be present ("newer_minor").
```

These helpers are optional — hooks may be plain shell scripts or standalone
programs that parse the JSON directly. The version check logic is intentionally
simple enough to reimplement in any language.

## Hook log format

Each hook invocation produces a log file in `<teamDir>/hook-logs/` with the structure:
Expand Down
124 changes: 122 additions & 2 deletions extensions/teams/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,35 @@ import { getTeamsHooksDir } from "./paths.js";
import type { TeamTask } from "./task-store.js";

/**
* Hook contract version. Increment on breaking changes only.
* See docs/hook-contract.md for the full compatibility policy.
* Hook contract version. Increment on **breaking** changes only.
*
* Evolution rules (codified from docs/hook-contract.md):
*
* Additive (no bump):
* - New optional fields in context JSON
* - New environment variables
* - New event types
* - New metadata keys
* - Increasing truncation limits
*
* Breaking (bump required):
* - Removing / renaming existing JSON fields
* - Changing a field's type or semantics
* - Reducing truncation limits below current values
* - Changing exit-code semantics
* - Removing environment variables
*
* When bumping: keep the previous version supported for at least one minor
* release, emit a deprecation warning, then remove in the next major.
*/
export const HOOK_CONTRACT_VERSION = 1;

/**
* Minimum contract version this runtime supports.
* Used by `checkHookContractVersion` to validate version values.
*/
export const HOOK_CONTRACT_VERSION_MIN = 1;

export type TeamsHookEvent = "idle" | "task_completed" | "task_failed";

export type TeamsHookInvocation = {
Expand Down Expand Up @@ -74,6 +98,102 @@ export type TeamsHookRunResult = {
contractVersion: typeof HOOK_CONTRACT_VERSION;
};

/**
* Result of checking a hook contract version against this runtime's range.
*
* - `compatible`: version is within the supported range
* - `newer_minor`: version has the same major but is newer (additive fields OK)
* - `unsupported`: version is outside the supported range (breaking mismatch)
*/
export type HookVersionCheckResult = {
status: "compatible" | "newer_minor" | "unsupported";
requestedVersion: number;
currentVersion: typeof HOOK_CONTRACT_VERSION;
minVersion: typeof HOOK_CONTRACT_VERSION_MIN;
message: string;
};

/**
* Check whether a requested hook contract version is compatible with this
* runtime. Hook authors should call this to fail fast on breaking mismatches
* and tolerate additive changes.
*
* Rules:
* - `version < HOOK_CONTRACT_VERSION_MIN` → unsupported (too old)
* - `version === HOOK_CONTRACT_VERSION` → compatible (exact match)
* - `version > HOOK_CONTRACT_VERSION` → newer_minor (unknown additive
* fields may appear; hooks written defensively will still work)
* - `version` between min and current → compatible (within range)
*
* Hooks should treat `newer_minor` as a warning, not a hard failure.
*/
export function checkHookContractVersion(version: number): HookVersionCheckResult {
if (!Number.isInteger(version) || version < HOOK_CONTRACT_VERSION_MIN) {
return {
status: "unsupported",
requestedVersion: version,
currentVersion: HOOK_CONTRACT_VERSION,
minVersion: HOOK_CONTRACT_VERSION_MIN,
message: `Hook contract version ${version} is below the minimum supported version ${HOOK_CONTRACT_VERSION_MIN}. Update your hook script.`,
};
}
if (version > HOOK_CONTRACT_VERSION) {
return {
status: "newer_minor",
requestedVersion: version,
Comment on lines +140 to +143
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject future contract versions as unsupported

The new compatibility helper treats any version > HOOK_CONTRACT_VERSION as newer_minor, but this repo’s policy states that additive changes do not bump the version and only breaking changes do. In that model, a higher version means the payload may be incompatible, so returning non-fatal status will let hooks continue parsing potentially broken shapes instead of failing fast. This is especially risky because parseHookContextSafe now depends on this check and will accept those future versions as success.

Useful? React with 👍 / 👎.

currentVersion: HOOK_CONTRACT_VERSION,
minVersion: HOOK_CONTRACT_VERSION_MIN,
message: `Hook contract version ${version} is newer than the current runtime version ${HOOK_CONTRACT_VERSION}. Additive fields may appear; ensure your hook tolerates unknown keys.`,
};
}
return {
status: "compatible",
requestedVersion: version,
currentVersion: HOOK_CONTRACT_VERSION,
minVersion: HOOK_CONTRACT_VERSION_MIN,
message: `Hook contract version ${version} is compatible with runtime version ${HOOK_CONTRACT_VERSION}.`,
};
}

/**
* Safely parse `PI_TEAMS_HOOK_CONTEXT_JSON` from environment, validate the
* version field, and return a typed result. This is the recommended entry
* point for hook scripts that want version-aware parsing.
*
* Returns `{ ok: true, payload, versionCheck }` on success (including
* `newer_minor`), or `{ ok: false, error, versionCheck? }` on failure.
*/
export function parseHookContextSafe(json: string): {
ok: true;
payload: HookContextPayload;
versionCheck: HookVersionCheckResult;
} | {
ok: false;
error: string;
versionCheck?: HookVersionCheckResult;
} {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return { ok: false, error: "Failed to parse PI_TEAMS_HOOK_CONTEXT_JSON as JSON." };
}
if (typeof parsed !== "object" || parsed === null) {
return { ok: false, error: "PI_TEAMS_HOOK_CONTEXT_JSON is not a JSON object." };
}
const obj = parsed as Record<string, unknown>;
if (typeof obj.version !== "number") {
return { ok: false, error: "Missing or non-numeric 'version' field in hook context JSON." };
}
const versionCheck = checkHookContractVersion(obj.version);
if (versionCheck.status === "unsupported") {
return { ok: false, error: versionCheck.message, versionCheck };
}
// For compatible and newer_minor: return the payload.
// The caller tolerates unknown keys (per contract policy).
return { ok: true, payload: parsed as HookContextPayload, versionCheck };
}

function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
return typeof err === "object" && err !== null && "code" in err;
}
Expand Down
Loading
Loading