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
25 changes: 25 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,31 @@ The repository-root `docs/` directory: skill configuration and this
repository's ADRs. Unrelated to the Mirror despite the shared word.
_Avoid_: docs (unqualified), the docs folder

## The product

The three terms this repository does not own but has to speak, because a word
that means one thing in the Guide and another on the Landing is how a site
starts contradicting itself. All three are authoritative in the Guide, under
`deployment-shapes`.

**Shape**:
Which of the two forms an Argus installation is running in, derived from one
fact: whether an LLM provider is configured. Never picked by a setting, so the
Landing describes Shapes rather than offering a choice between them.
_Avoid_: mode, tier, plan, edition

**Toolbox**:
The Shape with no provider configured. Argus serves its scanners, the
organization's knowledge, its memory and its skills, and the reader's own AI
tool does the reasoning. The floor, and never a trial or a crippled mode.
_Avoid_: agentless, tooling mode, free tier, MCP-only

**Colleague**:
The Shape with a provider configured. Everything a Toolbox serves, plus Argus
reasoning on its own behalf. A storey above a Toolbox, never a different
building.
_Avoid_: agentic mode, full version, pro

## The landing

**Landing**:
Expand Down
16 changes: 16 additions & 0 deletions docs/adr/0012-the-landing-speaks-the-guides-shape-vocabulary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# The Landing speaks the Guide's shape vocabulary, and never says agentless

The Guide names two deployment shapes — **Toolbox** with no LLM provider
configured, **Colleague** with one — and derives the shape from that single fact
rather than from a setting. The Landing adopts both words unchanged instead of
inventing its own, because this repository publishes prose it does not author,
and a term that means one thing upstream and another on the home page is how a
site starts contradicting itself. It already had: the third story beat closed on
"A colleague, not a toolbox" while Toolbox was a shipped shape.

*Agentless* was the tempting word for what a Toolbox is, and it is refused. In
application security the word is already taken and means "nothing installed on
the host" — and a Toolbox is a daemon running on the machine the code is on,
scanning that host's own filesystem. To the readers most likely to know the term
it would be a claim about topology, and a false one. What it was reaching for is
a sentence the Guide already has: *your agent does the reasoning*.
20 changes: 20 additions & 0 deletions docs/adr/0013-the-landing-ascends-and-the-hero-does-not.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# The Landing ascends from Toolbox to Colleague, and the hero does not

The middle of the page climbs. It starts where a reader can start today — a
Toolbox their own AI tool drives, with no model to pay for — and then adds the
model that makes Argus a Colleague. Two reasons: it is the product's own shape,
since a Colleague is a storey above a Toolbox rather than a different building;
and the page previously had no ascent at all, opening at its maximum with Argus
arguing back and then descending into configuration. Trust closes the page for
the same reason, so the boundaries land after the climb instead of interrupting
it.

The hero deliberately does not carry the Toolbox pitch, and stays the thesis. A
reader at the top has not yet learned that Argus uses a model, so "no model
required" up there answers an objection nobody has raised, and reads as
defensive. Worse, an opening that sells the tool the reader drives sells the
product below its own ceiling: the first thing a stranger would learn is a
protocol and a subordinate role. The zero-cost first step is the strongest
acquisition argument we have, and it is not lost by this — it lands thirty
seconds in, in the first section of the climb, and in the meta description that
link previews show.
50 changes: 50 additions & 0 deletions src/landing/bands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';

import { raisedIn } from './bands';

/**
* The alternation is the page's rhythm, and a broken rhythm is the kind of
* failure nobody notices in review: two raised sections in a row read as one
* long section, and the reader loses the boundary the design was using to say
* "new argument". Nothing turns red, and the page looks almost right.
*/
describe('the raised band', () => {
const isRaised = raisedIn(['hero', 'one', 'two', 'three', 'four', 'five']);

it('leaves the hero on the ground', () => {
// It closes with a rule instead of a change of band, so raising it would
// give it two endings.
expect(isRaised('hero')).toBe(false);
});

it('leaves the section under the hero on the ground too', () => {
// The hero's own rule is the boundary there; a band as well would draw the
// same line twice.
expect(isRaised('one')).toBe(false);
});

it('alternates from the second section down', () => {
expect(['two', 'three', 'four', 'five'].map(isRaised)).toEqual([true, false, true, false]);
});

it('holds the alternation whatever the section is called', () => {
// The rule is positional. A section renamed keeps its band, which is what
// lets the page be reordered by moving markup rather than by re-deciding
// every band by hand.
expect(['hero', 'a', 'b'].map(raisedIn(['hero', 'a', 'b']))).toEqual([false, false, true]);
});

it('refuses a section the page never declared', () => {
// Which is what makes this a build failure rather than a silent one: a
// typo in a section's id, or a section added to the markup and not to the
// order, stops the page being rendered at all.
expect(() => isRaised('your-editor')).toThrow(/your-editor/);
});

it('refuses an order that names a section twice', () => {
// Two sections sharing an id would each ask the same question and get the
// same answer, so one of them lands on the wrong band. A duplicate is a
// mistake in the page, not an input to interpret.
expect(() => raisedIn(['hero', 'one', 'one'])).toThrow(/one/);
});
});
47 changes: 47 additions & 0 deletions src/landing/bands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Which of the Landing's sections stand on the raised band.
*
* The page alternates: `bg`, then `surface`, then `bg` again, so each section
* announces itself as a new argument without needing a heading rule. That is a
* property of the *sequence*, not of any section, and it used to be decided
* section by section in the markup — which works right up until the page is
* reordered, and then it silently doesn't. Two raised sections in a row read as
* one long section and the boundary the design was drawing disappears.
*
* So the page declares its order once, at the top, and every section asks. A
* section moved keeps the band of its new position rather than the band it was
* written with, which is what makes reordering the Landing an edit to markup
* instead of a re-decision about every band on the page.
*
* The first two sections are both on the ground. The hero is the first, and it
* closes with a rule rather than with a change of band — raising the section
* under it would draw that same boundary twice.
*
* Asking about a section the order does not name throws, so a typo or a section
* added to the markup and not to the order fails the build. The alternative is
* the failure this exists to prevent, arriving silently by another door.
*/
export function raisedIn(order: readonly string[]): (id: string) => boolean {
const raised = new Map<string, boolean>();

order.forEach((id, index) => {
if (raised.has(id)) {
throw new Error(
`the Landing names the section \`${id}\` twice — two sections sharing an id cannot sit on different bands`,
);
}

raised.set(id, index >= 2 && index % 2 === 0);
});

return (id) => {
const band = raised.get(id);
if (band === undefined) {
throw new Error(
`the Landing renders a section \`${id}\` its order never names — add it to the order, in the place it is rendered`,
);
}

return band;
};
}
95 changes: 95 additions & 0 deletions src/landing/landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,103 @@ describe('the Landing', () => {
expect(value, `${property}: ${value}`).toMatch(/var\(--color-/);
}
});

it('uses none of the words the glossary refuses for the product\'s own terms', () => {
// The Landing has to speak Toolbox and Colleague, and it does not own them:
// they are the Guide's words. `CONTEXT.md` is where that is written down,
// including the words each one is *not* — so the list is read out of the
// glossary rather than copied here, and adding a trap there arms this test
// with no edit. ADR 0012 is the one that matters: `agentless` already means
// "nothing installed on the host" to this page's audience, and a Toolbox is
// a daemon on the machine the code is on.
const refused = glossaryAvoids('The product');

expect(refused, 'no `_Avoid_` under `## The product` — has the glossary moved?').not.toHaveLength(
0,
);

for (const word of refused) {
const used = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
expect(copy(), `the glossary refuses \`${word}\``).not.toMatch(used);
}
});

it('names both shapes the Guide names', () => {
// The other half of the same rule, and the half that goes wrong quietly.
// ADR 0012 adopts Toolbox and Colleague *because* this page has to speak
// them, and a page that refuses `agentless` while never saying `colleague`
// has kept the letter of that and dropped the point. The word only ever
// reached the page inside one claim, so deleting the claim deleted the
// vocabulary — which is what happened, and what this now catches.
for (const shape of ['toolbox', 'colleague']) {
expect(copy(), `ADR 0012 adopts \`${shape}\`, and the page never says it`).toMatch(
new RegExp(`\\b${shape}\\b`, 'i'),
);
}
});

it('writes no snake_case name anywhere in its copy', () => {
// The Inventory rule, over the one class of name that always breaks it. An
// MCP tool, a config key and an environment variable are all snake_case,
// and all three are enumerations Argus changes without telling this
// repository — the tool surface gained the scanners the week this page was
// last rewritten. So the page describes what happened in verbs and names
// none of them; a link goes to the Guide, which owns the list.
expect(copy()).not.toMatch(/\b[a-z][a-z0-9]*_[a-z0-9_]+\b/);
});

it('renders every section its declared order names', () => {
// `bandOf` fails the build on a section the order does not name. This is
// the other direction, which nothing at build time can see: an id left in
// the order after its section went shifts the band of every section under
// it, and the page still renders.
const declaration = /const ORDER = \[([\s\S]*?)\] as const;/.exec(SOURCE);

expect(declaration, 'the Landing declares no section order').not.toBeNull();

const declared = [...declaration![1]!.matchAll(/'([^']+)'/g)].map(([, id]) => id!);
// Everything past the declaration, and never the declaration itself: the
// ids are written there, so a search that included it would find each one
// in its own definition and pass whatever the page renders.
const rest = SOURCE.slice(declaration!.index + declaration![0].length);

expect(declared, 'the Landing declares an empty section order').not.toHaveLength(0);

for (const id of declared) {
expect(rest, `\`${id}\` is declared and never rendered`).toContain(id);
}
});
});

/**
* Everything on the page that can carry a word the reader sees: its markup and
* the frontmatter its copy is declared in, with the stylesheet and the comments
* taken out. A comment explaining why a word is refused must not itself read as
* the page using that word — the same reason `stylesheet()` below blanks them.
*
* Not prose, and deliberately not: most of the copy is declared in TypeScript, so
* anything narrower than this would police the half of the page that happens to
* be markup and leave the half that is data alone.
*/
function copy(): string {
return SOURCE.replace(/<style>[\s\S]*?<\/style>/g, ' ')
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/^[ \t]*\/\/.*$/gm, ' ');
}

/** The `_Avoid_` words under one heading of the glossary, in the order written. */
function glossaryAvoids(heading: string): string[] {
const context = readFileSync(join(REPO_ROOT, 'CONTEXT.md'), 'utf8');
const section = new RegExp(`^## ${heading}$([\\s\\S]*?)(?=^## |\\Z)`, 'm').exec(context)?.[1];

expect(section, `CONTEXT.md has no \`## ${heading}\``).toBeDefined();

return [...section!.matchAll(/^_Avoid_:(.*)$/gm)]
.flatMap(([, list]) => list!.split(','))
.map((word) => word.trim())
.filter((word) => word !== '');
}

/** The Landing's stylesheet with its comments blanked, so the prose explaining
* why gold fills in light is never mistaken for a colour value. */
function stylesheet(): string {
Expand Down
Loading