Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
af4fa1c
fix(deps): regenerate lockfile with packages/agent-manifest entry
claude Jul 31, 2026
2a8325b
fix(calibration): stop double-demoting on a severe miss; fix window=0
claude Jul 31, 2026
33e9f1f
fix(content-analyst): close n-threshold bypass via omitted figure.source
claude Jul 31, 2026
cbaaba9
fix(queue): require a named human for demote(); dedupe agent-identity…
claude Jul 31, 2026
2395e1c
fix(capture): drop expired briefs from the ready queue on sweep
claude Jul 31, 2026
d7f92b1
fix(content-monitor): enforce published-has-review invariant
claude Jul 31, 2026
b91fce4
fix(content-distributor): strip every occurrence of a carried claim, …
claude Jul 31, 2026
f457b8f
fix(content-studio): fail loudly for a surface with no Payload collec…
claude Jul 31, 2026
7feab7a
fix(tsconfig): typecheck agents/** — it was invisible to pnpm typecheck
claude Jul 31, 2026
6ee7809
fix(content-pipeline): corpusSlugs includes the published mirror, not…
claude Jul 31, 2026
902cf28
fix(gates): report a crashed gate under its canonical id
claude Jul 31, 2026
42626b7
refactor(cli): extract shared --flag arg parser, dedupe 10 copies
claude Jul 31, 2026
41c67c9
refactor(access): dedupe shared prefix in agentCannotPublish/DraftsOnly
claude Jul 31, 2026
3e76b9f
refactor(links): share external-link resolution; fix abort-timer leak
claude Jul 31, 2026
065ba84
perf(gates-cli): run per-brief gate suites concurrently; hoist corpus…
claude Jul 31, 2026
599ef1e
Merge remote-tracking branch 'origin/main' into claude/codebase-revie…
claude Jul 31, 2026
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
7 changes: 6 additions & 1 deletion agents/content-analyst/agent/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,12 @@ export class ReadBuilder {
if (!figure.query?.trim() || figure.n === undefined || !figure.window?.trim()) {
throw new Error('every figure carries its exact query, n and window — no exceptions');
}
if (figure.direction && figure.source) {
if (figure.direction) {
if (!figure.source) {
throw new Error(
`a directional figure needs its source (n=${figure.n}) — the n-threshold can't be checked without knowing which source's threshold applies; report the figure without a direction instead`,
);
}
const threshold = this.nThreshold[figure.source];
if (threshold !== undefined && figure.n < threshold) {
throw new NThresholdError(figure.source, figure.n, threshold);
Expand Down
10 changes: 10 additions & 0 deletions agents/content-analyst/tests/analyst.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ describe('the n-threshold gate', () => {
),
).not.toThrow();
});

it('a directional claim with no source is rejected, however small n is — the threshold cannot be checked blind', () => {
expect(() =>
builder().addFinding(
finding({
figures: [{ value: 12, query: 'ga4: sessions cluster=recipes', n: 3, window: '7d', direction: 'up' }],
}),
),
).toThrow(/needs its source/);
});
});

describe('reads, predictions and the run report', () => {
Expand Down
2 changes: 1 addition & 1 deletion agents/content-distributor/agent/adapt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const checkAdaptation = (
}
const carriedText = adaptation.claims.map((c) => c.text).join(' ');
let outside = fullText;
for (const c of adaptation.claims) outside = outside.replace(c.text, ' ');
for (const c of adaptation.claims) outside = outside.split(c.text).join(' ');
for (const category of policy.categories) {
for (const pattern of category.patterns) {
const re = new RegExp(pattern, 'i');
Expand Down
6 changes: 2 additions & 4 deletions agents/content-distributor/agent/sends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,13 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { parse as parseYaml, stringify as toYaml } from 'yaml';
import { looksLikeAgent } from '../../../packages/content-pipeline/src/humanApproval';
import { adaptationHash, type Adaptation } from './adapt';

const distDir = (root: string) => path.join(root, '.agency', 'distribution');

export class SendNotApprovedError extends Error {}

/** Looks like an agent, not a person — same refusal as queue promotion. */
const agentLike = /^$|agent|bot|studio|planner|analyst|monitor|distributor|desk|-qa$|^ci$/i;

export interface SendApproval {
sendId: string;
slug: string;
Expand All @@ -30,7 +28,7 @@ export interface SendApproval {

/** A human approves one specific send of one specific adaptation. */
export const approveSend = (root: string, adaptation: Adaptation, approver: string): SendApproval => {
if (agentLike.test(approver.trim())) {
if (looksLikeAgent(approver)) {
throw new SendNotApprovedError(`approval requires a named human (got "${approver}") — a human confirms each send, per send`);
}
const contentHash = adaptationHash(adaptation);
Expand Down
10 changes: 10 additions & 0 deletions agents/content-distributor/tests/distributor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ describe('published work is adapted, not rewritten', () => {
expect(result.failures).toEqual([]);
});

it('a carried claim repeated twice in the body is not falsely flagged — every occurrence is stripped, not just the first', () => {
const claimText = 'Grass-finishing lifts omega-3 precursors relative to grain-finishing';
const repeated = adaptation({
claims: [{ claimId: 'c1', text: claimText }],
body: `${claimText} — a hook worth repeating. ${claimText}. Full story: https://carinyaparc.com.au/slow-roasted-highland-beef.`,
});
const result = checkAdaptation(repeated, source, newsletterSpec, policy);
expect(result.failures).toEqual([]);
});

it('missing canonical link and prohibited claims are refused', () => {
const noLink = adaptation({ body: 'A lovely roast. The end, no link anywhere in this body at all, which is the problem being tested.' });
expect(checkAdaptation(noLink, source, newsletterSpec, policy).failures.join(' ')).toContain('canonical');
Expand Down
56 changes: 31 additions & 25 deletions agents/content-monitor/agent/invariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* corpus, these decide. No model calls.
*/
import { collectClaims, collectLinks, type LexicalDocument } from '../../../packages/content-pipeline/src/lexical/claim';
import { internalSlug } from '../../../packages/content-pipeline/src/gates/links';
import { internalSlug, resolveExternalLink } from '../../../packages/content-pipeline/src/gates/links';
import type { BriefArtifact, ClaimPolicy, PackArtifact, SurfaceSpec } from '../../../packages/content-pipeline/src/gates/types';

export interface CorpusDoc {
Expand Down Expand Up @@ -64,6 +64,19 @@ export const checkBriefAndPack = (
return violations;
};

/** Every published document has a review record — the invariant review.schema.json's own description asserts. */
export const checkPublishedHasReview = (published: CorpusDoc[], reviewSlugs: string[]): Violation[] => {
const reviews = new Set(reviewSlugs);
return published
.filter((doc) => !reviews.has(doc.slug))
.map((doc) => ({
invariant: 'published-has-review',
page: doc.slug,
title: `published page "${doc.slug}" has no review record`,
evidence: `no .agency/content/reviews/${doc.slug}.yaml — the calibration/shadow pipeline treats review records as its sole input`,
}));
};

/** The cannibalisation check: every targetQuery maps to exactly one canonical page. */
export const checkTargetQueryUniqueness = (briefs: BriefArtifact[]): Violation[] => {
const byQuery = new Map<string, string[]>();
Expand Down Expand Up @@ -118,30 +131,23 @@ export const checkExternalLinks = async (

const violations: Violation[] = [];
for (const [url, pages] of urls) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
let res = await fetchImpl(url, { method: 'HEAD', redirect: 'follow', signal: controller.signal });
if (res.status === 405 || res.status === 501) {
res = await fetchImpl(url, { method: 'GET', redirect: 'follow', signal: controller.signal });
}
clearTimeout(timer);
if (res.status >= 400) {
violations.push({
invariant: 'external-link-resolves',
page: pages[0],
title: `external link returns ${res.status}`,
evidence: `${url} (used by: ${[...new Set(pages)].join(', ')})`,
});
}
} catch (err) {
violations.push({
invariant: 'external-link-resolves',
page: pages[0],
title: 'external link did not resolve',
evidence: `${url} — ${(err as Error).message} (used by: ${[...new Set(pages)].join(', ')})`,
});
}
const resolution = await resolveExternalLink(url, fetchImpl);
if (resolution.ok) continue;
violations.push(
'status' in resolution
? {
invariant: 'external-link-resolves',
page: pages[0],
title: `external link returns ${resolution.status}`,
evidence: `${url} (used by: ${[...new Set(pages)].join(', ')})`,
}
: {
invariant: 'external-link-resolves',
page: pages[0],
title: 'external link did not resolve',
evidence: `${url} — ${resolution.error} (used by: ${[...new Set(pages)].join(', ')})`,
},
);
}
return violations;
};
Expand Down
15 changes: 10 additions & 5 deletions agents/content-monitor/agent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import path from 'node:path';
import process from 'node:process';
import { parse as parseYaml } from 'yaml';
import { loadBrand, loadBrief, listBriefSlugs, repoPaths } from '../../../packages/content-pipeline/src/artifacts';
import { arg } from '../../../packages/content-pipeline/src/cliArgs';
import type { BriefArtifact, PackArtifact } from '../../../packages/content-pipeline/src/gates/types';
import {
checkBriefAndPack,
Expand All @@ -20,17 +21,13 @@ import {
checkInternalLinkGraph,
checkMustSupportStillResolves,
checkPositioningHash,
checkPublishedHasReview,
checkSourceFreshness,
type CorpusDoc,
type Violation,
} from './invariants';
import { Triage } from './triage';

const arg = (name: string, fallback?: string): string | undefined => {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : fallback;
};

const readJsonDir = <T>(dir: string): T[] => {
if (!existsSync(dir)) return [];
return readdirSync(dir)
Expand Down Expand Up @@ -105,8 +102,16 @@ const main = async () => {
.map((f) => parseYaml(readFileSync(path.join(packsDir, f), 'utf8')) as PackArtifact)
: [];

const reviewsDir = path.join(paths.content, 'reviews');
const reviewSlugs = existsSync(reviewsDir)
? readdirSync(reviewsDir)
.filter((f) => f.endsWith('.yaml'))
.map((f) => f.replace(/\.yaml$/, ''))
: [];

const violations: Violation[] = [
...checkBriefAndPack(published, briefSlugs, packs.map((p) => p.slug), corpus.map((c) => c.slug)),
...checkPublishedHasReview(published, reviewSlugs),
...checkTargetQueryUniquenessSafe(briefs),
...checkInternalLinkGraph(corpus),
...(external === 'check' ? await checkExternalLinks(corpus, packs, fetch) : []),
Expand Down
9 changes: 9 additions & 0 deletions agents/content-monitor/tests/invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
checkInternalLinkGraph,
checkMustSupportStillResolves,
checkPositioningHash,
checkPublishedHasReview,
checkSourceFreshness,
checkTargetQueryUniqueness,
type CorpusDoc,
Expand Down Expand Up @@ -72,6 +73,14 @@ describe('slug join and orphans, both directions', () => {
});
});

describe('published pages have a review record', () => {
it('a published page with no review record is flagged; a reviewed one is not', () => {
const violations = checkPublishedHasReview([doc('reviewed'), doc('unreviewed')], ['reviewed']);
expect(violations).toHaveLength(1);
expect(violations[0]).toMatchObject({ invariant: 'published-has-review', page: 'unreviewed' });
});
});

describe('the cannibalisation check', () => {
it('two briefs targeting one query are flagged, case-insensitively', () => {
const violations = checkTargetQueryUniqueness([
Expand Down
27 changes: 21 additions & 6 deletions agents/content-planner/agent/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { parse as parseYaml, stringify as toYaml } from 'yaml';
import { looksLikeAgent } from '../../../packages/content-pipeline/src/humanApproval';

export interface ReadyQueue {
cap: number;
Expand All @@ -31,17 +32,13 @@ export const queueHasRoom = (contentDir: string): boolean => {
return queue.ready.length < queue.cap;
};

/** Looks like an agent identity, not a person. The gate errs toward refusing. */
const agentLike = /^$|agent|bot|studio|planner|analyst|monitor|distributor|desk|-qa$|^ci$/i;

export class PromotionDeniedError extends Error {}

/**
* The promotion gate. Only a named human promotes, and never past the cap.
* There is deliberately no other writer of queue.yaml in the codebase.
*/
export const promote = (contentDir: string, slug: string, by: string): ReadyQueue => {
if (agentLike.test(by.trim())) {
if (looksLikeAgent(by)) {
throw new PromotionDeniedError(
`promotion requires a named human (got "${by}") — the planner files to Triage; a person decides what enters the queue`,
);
Expand All @@ -61,9 +58,27 @@ export const promote = (contentDir: string, slug: string, by: string): ReadyQueu
return queue;
};

export const demote = (contentDir: string, slug: string): ReadyQueue => {
const removeFromQueue = (contentDir: string, slug: string): ReadyQueue => {
const queue = loadQueue(contentDir);
queue.ready = queue.ready.filter((s) => s !== slug);
writeFileSync(queueFile(contentDir), toYaml(queue));
return queue;
};

/** The demotion gate. Only a named human demotes — same guard as promotion. */
export const demote = (contentDir: string, slug: string, by: string): ReadyQueue => {
if (looksLikeAgent(by)) {
throw new PromotionDeniedError(
`demotion requires a named human (got "${by}") — a person decides what leaves the queue, same as what enters it`,
);
}
return removeFromQueue(contentDir, slug);
};

/**
* System cleanup only: drops a slug whose backing brief has expired. Not a
* priority decision, so it carries no human-approver check — see `demote`
* for that. Used by the weekly expiry sweep; other callers should use
* `demote`.
*/
export const pruneExpiredFromQueue = (contentDir: string, slug: string): ReadyQueue => removeFromQueue(contentDir, slug);
25 changes: 23 additions & 2 deletions agents/content-planner/tests/planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest';
import { parse as parseYaml, stringify as toYaml } from 'yaml';
import { OpportunitiesBuilder } from '../agent/opportunities';
import { commission, TargetQueryCollisionError, type CommissionInput } from '../agent/commission';
import { loadQueue, promote, PromotionDeniedError } from '../agent/queue';
import { demote, loadQueue, promote, PromotionDeniedError, pruneExpiredFromQueue } from '../agent/queue';
import { Triage } from '../../content-monitor/agent/triage';
import type { ClaimPolicy } from '../../../packages/content-pipeline/src/gates/types';

Expand Down Expand Up @@ -80,7 +80,7 @@ const commissionInput = (over: Partial<CommissionInput> = {}): CommissionInput =
// give the opportunity an id like the builder would
const withId = (input: CommissionInput): CommissionInput => ({
...input,
opportunity: { id: 'opp-how-to-read-a-soil-test', ...input.opportunity },
opportunity: { ...input.opportunity, id: 'opp-how-to-read-a-soil-test' },
});

describe('commissioning', () => {
Expand Down Expand Up @@ -143,6 +143,27 @@ describe('the capped, human-promoted queue', () => {
expect(() => promote(contentDir, b.slug, 'Jonno')).toThrow(/cap/);
});

it('demotion requires a named human; agent-shaped names are refused', () => {
const root = scaffoldRepo();
const contentDir = path.join(root, '.agency/content');
const { slug } = commission(root, withId(commissionInput()), new Date('2026-08-01'));
writeFileSync(path.join(contentDir, 'queue.yaml'), toYaml({ cap: 2, ready: [] }));
promote(contentDir, slug, 'Jonno');

expect(() => demote(contentDir, slug, 'content-planner')).toThrow(PromotionDeniedError);
expect(demote(contentDir, slug, 'Jonno').ready).not.toContain(slug);
});

it('the expiry sweep may prune a stale slug without a human name', () => {
const root = scaffoldRepo();
const contentDir = path.join(root, '.agency/content');
const { slug } = commission(root, withId(commissionInput()), new Date('2026-08-01'));
writeFileSync(path.join(contentDir, 'queue.yaml'), toYaml({ cap: 2, ready: [] }));
promote(contentDir, slug, 'Jonno');

expect(pruneExpiredFromQueue(contentDir, slug).ready).not.toContain(slug);
});

it('a missing or invalid cap defaults to 3 rather than disabling the gate', () => {
const root = scaffoldRepo();
const contentDir = path.join(root, '.agency/content');
Expand Down
16 changes: 14 additions & 2 deletions agents/content-studio/agent/stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,20 @@ export interface StageResult {
url: string;
}

const collectionFor = (draft: DraftArtifact): 'posts' | 'recipes' =>
draft.collection ?? (draft.surface === 'recipes' ? 'recipes' : 'posts');
const collectionFor = (draft: DraftArtifact): 'posts' | 'recipes' => {
if (draft.collection) return draft.collection;
switch (draft.surface) {
case 'blog':
return 'posts';
case 'recipes':
return 'recipes';
case 'landing':
case 'newsletter':
throw new Error(
`surface "${draft.surface}" has no Payload collection to stage into — set draft.collection explicitly`,
);
}
};

const headers = (apiKey: string) => ({
Authorization: `users API-Key ${apiKey}`,
Expand Down
15 changes: 12 additions & 3 deletions agents/content-studio/tests/studio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { gateLoop } from '../agent/gateLoop';
import { PackCollector, SourceBudgetExceededError } from '../agent/pack';
import { renderRunReport } from '../agent/runReport';
import { stageDraft } from '../agent/stage';
import { collectClaims, textOf } from '../../../packages/content-pipeline/src/lexical/claim';
import { collectClaims, textOf, type SerializedClaimNode } from '../../../packages/content-pipeline/src/lexical/claim';
import { computeEdit } from '../../../packages/content-pipeline/src/review';
import type {
BrandDist,
Expand Down Expand Up @@ -32,9 +32,9 @@ const pack: PackArtifact = {
describe('claim anchoring', () => {
it('converts [[cN:text]] markers into claim nodes bound to pack entry ids', () => {
const nodes = anchorParagraph('We measured. [[c1:the number rose]] across paddocks.', pack);
const claimNodes = nodes.filter((n) => n.type === 'claim');
const claimNodes = nodes.filter((n): n is SerializedClaimNode => n.type === 'claim');
expect(claimNodes).toHaveLength(1);
expect((claimNodes[0] as { claimId: string }).claimId).toBe('c1');
expect(claimNodes[0].claimId).toBe('c1');
const docText = nodes.map((n) => textOf(n)).join('');
expect(docText).toContain('the number rose');
});
Expand Down Expand Up @@ -166,6 +166,15 @@ describe('REST staging as the agent identity', () => {
expect(body._status).toBe('draft');
expect(body.positioningHash).toBe('b'.repeat(64));
});

it('refuses to guess a Payload collection for a surface with none, rather than defaulting to posts', async () => {
await expect(
stageDraft(
{ slug: 's', title: 'T', surface: 'landing', content: { root: { type: 'root', children: [] } } },
{ baseUrl: 'https://cms.example', apiKey: 'KEY', fetchImpl: (async () => new Response('{}')) as typeof fetch },
),
).rejects.toThrow(/no Payload collection/);
});
});

describe('the pack collector', () => {
Expand Down
Loading
Loading