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
18 changes: 12 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ Owner invoke it — it fires by their hand alone, which is a decision rather tha

The source is `skills/learn/`, and editing it is `docs/adr/0017-…`'s business first.

### `/research-project`, the Research-project router

`/research-project <project identity> [-- what to work on]` reaches one synced Research-project
folder from any directory, then yields to that folder's live router and procedures. It is separate
from `/learn`: Research projects select among several project routes rather than one Module
Teaching workspace. The source is `skills/research-project/`; its boundary is `docs/adr/0025-…`.

### Issue tracker

GitHub Issues on this repository, via the `gh` CLI. `docs/agents/issue-tracker.md` carries the
Expand Down Expand Up @@ -135,9 +142,8 @@ exact-ID cleanup, interrupted-run reconciliation and the recovery boundary for r
`docs/module-folder-contract.md` names, so renaming one there changes where an importer puts
files. Read that repository's destination handling before touching the universal structure.

**Module folder paths are configuration.** Nothing here hardcodes a path into the Owner's
coursework — that path is precisely the thing being kept out of a public repository, so an
automation that needs one reads it from a file the ignore rules cover. The `/learn` skill is the
one exception, and `docs/adr/0017-…` argues it: a skill installed on a machine that holds nothing
else of this system cannot read the system's configuration, so it discovers the folder instead and
writes no path down.
**Academic folder paths are configuration.** Nothing here hardcodes a path into the Owner's work —
that path is precisely the thing being kept out of a public repository, so an automation that needs
one reads it from a file the ignore rules cover. The installed router skills are the exceptions:
`/learn` and `/research-project` run on machines that may hold nothing else of this system, so they
discover a folder and write no path down. `docs/adr/0017-…` and `docs/adr/0025-…` argue each route.
2 changes: 1 addition & 1 deletion MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Start here: `README.md`, then `AGENTS.md`.
| Domain language | The glossary — this repository's ubiquitous language | `CONTEXT.md` |
| Decisions | Architecture decision records | `docs/adr/` |
| Agent skills | The routines an agent follows here, one file per skill, reached by a pointer in `AGENTS.md` | `docs/agents/` |
| Installed skills | Harness skills this repository authors and a machine installs at user scope, invoked by name from any directory — today, `/learn` routing a teaching session into a module's own pinned procedure | `skills/`, `docs/adr/0017-…`, `docs/machine-setup.md` |
| Installed skills | User-invoked harness routes installed at user scope: `/learn` reaches one Module Teaching procedure; `/research-project` reaches one Research project's live router | `skills/`, `docs/adr/0017-…`, `docs/adr/0025-…`, `docs/machine-setup.md` |
| Research | Primary-source evidence that informs contracts, decisions and agent procedures | `docs/research/` |
| Automation | The workflows that run on a pull request or on a new issue, and dependency updates | `.github/` |
| Quality gates | Cross-file rule coverage, publication checks, and the seeded LaTeX set's compile check — all invoked by package scripts and CI | `scripts/check-contract-rule-coverage.mjs`, `src/privacy/`, `scripts/compile-seed-templates.mjs` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Research-project work has its own user-invoked router

`/research-project <project identity> [-- what to work on]` finds one synced Research-project
folder, reads its live router and project controls, selects the route the requested work belongs
to, then yields to that route. The skill carries no research procedure. It is installed at user
scope beside `/learn`, and fires only when the Owner invokes it.

## Why this is not `/learn`

`/learn` resolves a Module code one semester below `Modules/`, reads the Module's Teaching
procedure, chooses one Source-map unit and stays with the Owner through a teaching session. A
Research project has neither that identity nor that single route: its Definition supplies a stable
project identity, and its router separates Sources, Meetings, Research, Learning, Deliverables,
Tasks and Maintenance.

Making `/learn` detect both folder kinds would give one word two contracts. It would also make an
invocation that named only a project decide whether the Owner wanted Learning, source work or a
deliverable. The separate name keeps Module teaching unchanged and makes the second choice
visible.

## The skill is discovery and wayfinding

The skill searches the two supported macOS Drive mount families below `Modules/Research/` and
keeps candidates carrying a Project Definition. It matches the Owner's project argument against
the Definition's folder, key and title. Resolved paths are compared before selection because one
Drive may appear through both mount families; no match or genuinely distinct matches stop for the
Owner.

After selection, the skill reads `AGENTS.md`, `CONTEXT.md`, the Project Profile and the Project
Definition from the folder on every run. `AGENTS.md` owns the route names and the documents each
route requires. The skill names those routes only to select one; it copies none of their conduct,
artifact formats, registers or mathematical gates.

This is the same boundary ADR-0017 draws around `/learn`: the project folder travels with its
current rules, while an installed skill may be an older copy. Keeping the skill as a pointer makes
staleness fail at discovery or a missing route instead of silently applying an obsolete research
rule.

## It is user-invoked and self-sufficient

Research is too broad a word for implicit invocation, and selecting a project changes the whole
context of a session. The Owner therefore spends the invocation explicitly. Both harness
encodings prohibit implicit invocation.

The installed directory needs no repository clone, academic-os configuration or credential. A
machine that syncs Research-project folders has everything the router reads. The accepted cost is
the same as `/learn`: a copied skill changes only when it is copied again.

## Consequences

Module teaching still starts with `/learn <module code>`. Research-project work may start from any
directory with `/research-project <project identity> [-- what to work on]`, then follows the
selected folder's current controls. Ordinary work begun inside the project folder can still start
at `AGENTS.md`; the skill adds a route in, not a second procedure.

The skill may need amendment if Research projects move out of `Modules/Research/`, a third mount
family becomes supported, or project identity leaves the Definition. A change to research conduct
does not amend the skill; it changes the canonical pinned documents, then reaches each project by
an Owner-approved transition until a project-pinned refresh surface exists.
22 changes: 22 additions & 0 deletions docs/machine-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,25 @@ when someone sends it. A machine that holds the repository for other reasons may

Any further harness takes the same directory wherever it keeps user-scope skills, and gets its own
manifest beside `SKILL.md` if it needs one to know the skill fires only when the Owner says so.

## Optional: install the `/research-project` skill

`/research-project <project identity> [-- what to work on]` starts from any directory, discovers
one synced folder below `Modules/Research/`, and hands the work to that project's own router. It
does not replace `/learn`: `/learn` resolves a Module and always enters its Teaching workspace,
while a Research project selects among the routes its `AGENTS.md` currently declares. The split
is [ADR-0025](adr/0025-research-project-work-has-its-own-user-invoked-router.md).

The installation has the same boundary as `/learn`: one self-contained directory, no clone,
system configuration or credential required on the target machine. Copy it into each harness's
user-scope skills directory:

```sh
for harness in .claude .codex; do
rsync -a "<clone>/skills/research-project/" \
"<machine>:~/$harness/skills/research-project/"
done
```

Updating it repeats that command. A machine that already holds the repository may symlink the
directory instead.
73 changes: 73 additions & 0 deletions skills/research-project/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
name: research-project
description: Route work into one synced academic-os Research-project folder and its own live procedures.
disable-model-invocation: true
argument-hint: "<project identity> [-- what to work on]"
---

# Research project

The Owner wants to work in one Research-project folder.

This file is a route. It finds the project and then yields to that folder's router and procedures.
It carries no research conduct of its own; where this file and a project control disagree, the
project control wins and this file is wrong.

## 1. Find the candidates

Run this skill's bundled `scripts/find-candidates.zsh`. It searches both places macOS mounts a
Drive for directories immediately below `Modules/Research/`:

`$HOME/Library/CloudStorage/GoogleDrive-*/My Drive/Modules/Research/*` and
`/Volumes/*/My Drive/Modules/Research/*`.

The helper keeps only directories containing `00 Project Admin/10 Project Definition.yaml`,
resolves aliases and prints each resolved path once. Do not use the working directory or
configuration from the system that seeded them.

A machine can expose one Drive through both mount families. Compare every resolved candidate path,
so one folder reached by two aliases remains one candidate.

## 2. Select the project

Text before the first standalone `--` is the project identity. Text after it is the work request;
when there is no `--`, the whole invocation is the identity and no work was supplied.

Read each candidate's Project Definition. If any Definition cannot be read or parsed, stop and name
that candidate—the discovery set is not trustworthy. Match the invocation's project identity
against the Definition's `project.folder`, `project.key` and `project.title`, as well as the folder
basename.
Compare case-insensitively after trimming and treating runs of spaces, hyphens and underscores as
the same separator. Do not use a partial or fuzzy match.

Stop and ask when nothing matches or when matching leaves folders that are genuinely different.
Say the selected folder once before doing project work.

## 3. Load the project's controls

Read these from the selected folder every session:

- `AGENTS.md`
- `CONTEXT.md`
- `00 Project Admin/00 Project Profile.md`
- `00 Project Admin/10 Project Definition.yaml`

The first file is the live router. Follow its Start-here reads, including
`docs/00 Structure and Naming.md` before any file operation. Remembered project rules are stale
rules.

## 4. Choose the route

Map the requested work to exactly one route named by the live `AGENTS.md`: Sources, Meetings,
Research, Learning, Deliverables, Tasks or Maintenance. Read every document that route names,
including its register or template, before acting.

Ask which route when the request genuinely fits more than one or names no meaningful area. Do not
invent a route or use `/learn`: that skill resolves Module teaching workspaces, not Research
projects.

## 5. Work under the route

Run the requested work under the live router, its Safety section and the procedure loaded in step
4. Put every artifact, register change and parked result where those controls say. End this skill's
authority here.
5 changes: 5 additions & 0 deletions skills/research-project/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
interface:
display_name: "Research Project"
short_description: "Route work into a synced research project"
policy:
allow_implicit_invocation: false
25 changes: 25 additions & 0 deletions skills/research-project/scripts/find-candidates.zsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/zsh

set -eu
setopt null_glob

cloud_storage_root="${1:-$HOME/Library/CloudStorage}"
volumes_root="${2:-/Volumes}"

roots=(
"$cloud_storage_root"/GoogleDrive-*/My\ Drive/Modules/Research(N-/)
"$volumes_root"/*/My\ Drive/Modules/Research(N-/)
)

typeset -a emitted
for root in "${roots[@]}"; do
for candidate in "$root"/*(N-/); do
definition="$candidate/00 Project Admin/10 Project Definition.yaml"
[[ -f "$definition" ]] || continue

resolved="${candidate:A}"
(( ${emitted[(Ie)$resolved]} == 0 )) || continue
emitted+=("$resolved")
print -r -- "$resolved"
done
done
153 changes: 153 additions & 0 deletions test/skills/research-project-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
mkdir,
mkdtemp,
readFile,
realpath,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "node:test";
import { promisify } from "node:util";

const skillPath = "skills/research-project/SKILL.md";
const manifestPath = "skills/research-project/agents/openai.yaml";
const finderPath = "skills/research-project/scripts/find-candidates.zsh";
const execFileAsync = promisify(execFile);

async function readSkillText(): Promise<string> {
const parts = await Promise.all([
readFile(skillPath, "utf8"),
readFile(manifestPath, "utf8"),
]);
return parts.join("\n");
}

describe("the research-project skill", () => {
it("fires only when the Owner invokes it", async () => {
const [skill, manifest] = await Promise.all([
readFile(skillPath, "utf8"),
readFile(manifestPath, "utf8"),
]);

assert.match(skill, /^disable-model-invocation: true$/mu);
assert.match(manifest, /^ {2}allow_implicit_invocation: false$/mu);
});

it("discovers both supported Drive mount families without system configuration", async () => {
const text = await readSkillText();

assert.match(text, /Library\/CloudStorage\/GoogleDrive-/u);
assert.match(text, /\/Volumes\/\*/u);
assert.match(text, /Modules\/Research/u);
assert.doesNotMatch(text, /academic-os\.config\.json/u);
});

it("executes against a missing mount family and deduplicates resolved aliases", {
skip: process.platform !== "darwin",
}, async () => {
const fixture = await mkdtemp(join(tmpdir(), "research-project-skill-"));
const cloudRoot = join(fixture, "cloud");
const volumesRoot = join(fixture, "volumes");
const project = join(
cloudRoot,
"GoogleDrive-owner",
"My Drive",
"Modules",
"Research",
"Project One",
);
const definition = join(
project,
"00 Project Admin",
"10 Project Definition.yaml",
);
const volumeResearch = join(
volumesRoot,
"Disk",
"My Drive",
"Modules",
"Research",
);

try {
await mkdir(join(project, "00 Project Admin"), { recursive: true });
await writeFile(definition, "project:\n key: project-one\n", "utf8");
await mkdir(volumeResearch, { recursive: true });
await symlink(project, join(volumeResearch, "Project One"));

const both = await execFileAsync(finderPath, [cloudRoot, volumesRoot]);
assert.deepEqual(both.stdout.trim().split("\n"), [
await realpath(project),
]);

const volumeOnly = await execFileAsync(finderPath, [
join(fixture, "missing-cloud-root"),
volumesRoot,
]);
assert.deepEqual(volumeOnly.stdout.trim().split("\n"), [
await realpath(project),
]);
} finally {
await rm(fixture, { recursive: true, force: true });
}
});

it("selects by public project identity and resolves mount aliases", async () => {
const text = await readSkillText();

for (const identity of ["folder", "key", "title"]) {
assert.match(text, new RegExp(`\\b${identity}\\b`, "u"));
}
assert.match(text, /resolved/u);
assert.match(text, /genuinely different/u);
assert.match(text, /before the first standalone `--`/u);
assert.match(text, /cannot be read or parsed/u);
});

it("loads project controls and hands every area to the live router", async () => {
const text = await readFile(skillPath, "utf8");

for (const control of [
"AGENTS.md",
"00 Project Admin/00 Project Profile.md",
"00 Project Admin/10 Project Definition.yaml",
]) {
assert.match(
text,
new RegExp(control.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u"),
);
}

for (const route of [
"Sources",
"Meetings",
"Research",
"Learning",
"Deliverables",
"Tasks",
"Maintenance",
]) {
assert.match(text, new RegExp(`\\b${route}\\b`, "u"));
}
assert.match(text, /Read every document that route names/u);
});

it("contains neither project-specific identity nor copied research conduct", async () => {
const text = await readSkillText();

assert.doesNotMatch(
text,
/^(?:project|profile):|^ {2}(?:key|folder|title):/mu,
);
assert.doesNotMatch(text, /latexmk|-auxdir|-outdir/u);
assert.doesNotMatch(
text,
/candidate agent-written mathematics|Claim becomes `checked`/u,
);
});
});
Loading