diff --git a/AGENTS.md b/AGENTS.md index eaedf1d..9229068 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 [-- 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 @@ -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. diff --git a/MAP.md b/MAP.md index fa1dd58..2ed2d69 100644 --- a/MAP.md +++ b/MAP.md @@ -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` | diff --git a/docs/adr/0025-research-project-work-has-its-own-user-invoked-router.md b/docs/adr/0025-research-project-work-has-its-own-user-invoked-router.md new file mode 100644 index 0000000..994fbe9 --- /dev/null +++ b/docs/adr/0025-research-project-work-has-its-own-user-invoked-router.md @@ -0,0 +1,59 @@ +# Research-project work has its own user-invoked router + +`/research-project [-- 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 `. Research-project work may start from any +directory with `/research-project [-- 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. diff --git a/docs/machine-setup.md b/docs/machine-setup.md index 826bc04..7d4d2f7 100644 --- a/docs/machine-setup.md +++ b/docs/machine-setup.md @@ -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 [-- 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 "/skills/research-project/" \ + ":~/$harness/skills/research-project/" +done +``` + +Updating it repeats that command. A machine that already holds the repository may symlink the +directory instead. diff --git a/skills/research-project/SKILL.md b/skills/research-project/SKILL.md new file mode 100644 index 0000000..af99e68 --- /dev/null +++ b/skills/research-project/SKILL.md @@ -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: " [-- 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. diff --git a/skills/research-project/agents/openai.yaml b/skills/research-project/agents/openai.yaml new file mode 100644 index 0000000..9ef8ef7 --- /dev/null +++ b/skills/research-project/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Research Project" + short_description: "Route work into a synced research project" +policy: + allow_implicit_invocation: false diff --git a/skills/research-project/scripts/find-candidates.zsh b/skills/research-project/scripts/find-candidates.zsh new file mode 100755 index 0000000..db7ab25 --- /dev/null +++ b/skills/research-project/scripts/find-candidates.zsh @@ -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 diff --git a/test/skills/research-project-boundary.test.ts b/test/skills/research-project-boundary.test.ts new file mode 100644 index 0000000..ac7e7c4 --- /dev/null +++ b/test/skills/research-project-boundary.test.ts @@ -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 { + 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, + ); + }); +});