allye-pi 2.5.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Allye Assistant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -83,10 +83,15 @@ Then add the `allye` MCP server as described in [install-pi.md](docs/install-pi.
83
83
  export ALLYE_PAT=pat_... # Allye → Settings → API
84
84
  ./install.sh list --scope team # approved skills you can see
85
85
  ./install.sh install claude team-standards # claude | codex | opencode | pi | omp
86
+ ./install.sh install claude team-standards --reinstall # replace an edited copy (kept as a backup)
86
87
  ./install.sh status # offline integrity check
87
88
  ```
88
89
 
89
- Each install downloads the skill's approved release, verifies every file and the canonical hash, opens a distribution for this machine, verifies the API-signed execution token, publishes the files into the harness's user skills directory with a provenance sidecar, and records the result on the API. Local edits and directories it did not install are never overwritten. The API decides which runtimes may receive skills and who may distribute them; the installer reports its decision rather than working around it. Use `--team <id>` (or `ALLYE_TEAM_ID`) when you belong to several teams. Requires `bash`, `curl`, `jq` and Node.js 18+.
90
+ Each install downloads the skill's approved release, verifies every file and the canonical hash, opens a distribution for this machine, verifies the API-signed execution token, publishes the files into the harness's user skills directory with a provenance sidecar, and records the result on the API. The API decides which runtimes may receive skills; the installer reports its decision rather than working around it. Requires `bash`, `curl`, `jq` and Node.js 18+. The installer speaks compatibility contract `1.1.0`.
91
+
92
+ Until the allye-api PROD release that supports contract `1.1.0`, the installer works only against HML (`ALLYE_API_URL` pointing at the HML API). Against PROD it fails with `409 RUNTIME_INCOMPATIBLE` or an execution-context error about a missing signing key, and changes nothing.
93
+
94
+ Who can install, where skills go, how edited, deleted and moved copies are handled, and the API errors are in [docs/org-skills.md](docs/org-skills.md).
90
95
 
91
96
  ## Repository layout
92
97
 
@@ -94,7 +99,7 @@ Each install downloads the skill's approved release, verifies every file and the
94
99
  bootstrap/allye.md shared bootstrap text (single source)
95
100
  skills/bridge/ the Bridge skill and its references
96
101
  skills/bridge-*/ the crew skills (Armorer, Recon, Strategist, Architect, Dispatcher,
97
- Pilot, Copilot, Medic, Optimizer, Shield, Watcher)
102
+ Pilot, Navigator, Copilot, Medic, Optimizer, Shield, Watcher)
98
103
  agents/bridge-*.md native crew subagents (Claude Code; OpenCode registers them from here):
99
104
  load the matching crew skill under a per-role tool allowlist
100
105
  hooks/ Claude Code SessionStart hook
@@ -105,14 +110,14 @@ packages/allye-opencode/ OpenCode plugin (npm: allye-opencode)
105
110
  packages/allye-pi/ Pi/OMP extension (npm: allye-pi, root package.json)
106
111
  install.sh, install/ organization skills installer and its tests
107
112
  scripts/sync-bootstrap.sh copies the bootstrap to manifests/codex/AGENTS.md
108
- docs/ per-harness install and update guides
113
+ docs/ per-harness install and update guides, organization skills details
109
114
  ```
110
115
 
111
116
  ## Development
112
117
 
113
118
  ```bash
114
119
  npm install
115
- npm test # bootstrap, hook, crew agents, installer and Pi extension tests (offline)
120
+ npm test # bootstrap, hook, crew agents, skills, installer and docs gate, Pi and OpenCode tests (offline)
116
121
  npm run typecheck # Pi extension
117
122
  cd packages/allye-opencode && bun install && bun run typecheck && bun run build
118
123
  ```
@@ -125,4 +130,4 @@ Releases are automated by semantic-release on every push to `main` (Conventional
125
130
 
126
131
  ## License
127
132
 
128
- MIT
133
+ MIT, see [LICENSE](LICENSE).
@@ -22,10 +22,18 @@ OMP has a native MCP client (no `pi-mcp-adapter`). Add the server once, at user
22
22
 
23
23
  OMP also loads servers from a project `.mcp.json`. Do not add tokens or OAuth client metadata: OMP discovers the OAuth endpoints and opens the browser on first use.
24
24
 
25
- Because OMP's MCP client is not the in-process `pi-mcp-adapter` bridge, the extension does not preload Allye context in OMP. It injects only the git remote and any unverified `.allye/project.json` claim; the bootstrap tells the agent to call `projects.project_resolve` itself. The team follows the project, so no team selection is needed.
25
+ Because OMP's MCP client is not the in-process `pi-mcp-adapter` bridge (as in OMP today), the extension does not resolve the project. It injects only the git remote and any unverified `.allye/project.json` claim; the bootstrap tells the agent to call `projects.project_resolve` itself. The team follows the project, so no team selection is needed.
26
26
 
27
27
  ## Confirm
28
28
 
29
29
  Tell the user:
30
30
 
31
31
  > Allye is installed for OMP: the `allye-pi` extension (bootstrap and Bridge) and the `allye` MCP server. Start a new OMP session and run `/bridge survey` (or another mode).
32
+
33
+ ## Organization skills
34
+
35
+ `./install.sh install omp <skill>` (see the [README](../README.md#your-organizations-skills)) installs into `~/.omp/agent/skills`. OMP presents itself to the API as runtime `pi`, and the skill is installed as a plain directory, with no package receipt.
36
+
37
+ - Edited copies: `./install.sh install omp <skill> --reinstall` replaces your edited copy and keeps it at `~/.allye/backups/omp/<skill>.allye.backup.<UTC ts>`. A copy of the same release on the same target is already installed (a no-op); older-release copies are updated, and copies recorded for another target (after a hostname or skills-path change) are reinstalled when unchanged, while an edited one needs `--reinstall`. Foreign or unmanaged folders are never touched.
38
+ - Leftovers: if the installer reports a `.allye-backup.<skill>.*` entry in the skills directory, an earlier run kept your edited folder there. Move it into `~/.allye/backups/omp/` or delete it, then rerun.
39
+ - Any member with view access to a skill can install it. If the installer is too old for the API, upgrade the installer; there is nothing to ask an admin to reset. Until the allye-api PROD release the installer works only against HML. See the [README](../README.md#your-organizations-skills).
@@ -4,7 +4,7 @@ Allye for Pi is the native package `allye-pi`. It:
4
4
 
5
5
  - exposes the Bridge skill (`/bridge <mode>`) from the package's `skills/` directory;
6
6
  - adds the Allye bootstrap (`bootstrap/allye.md`) to the system prompt;
7
- - preloads Allye context (`initialize`, `intelligence`) when an in-process MCP bridge is available, and identifies the repository's project: it resolves the git remote with `projects.project_resolve` and verifies any `.allye/project.json` claim against it, injecting "this repo = project X / app Y (team Z)", the ambiguous candidates, or the disagreement to ask the user about.
7
+ - identifies the repository's project (it loads no startup or per-prompt memory context): when an in-process MCP bridge is available it resolves the git remote with `projects.project_resolve` and verifies any `.allye/project.json` claim against it, injecting "this repo = project X / app Y (team Z)", the ambiguous candidates, or the disagreement to ask the user about.
8
8
 
9
9
  It never edits Pi's MCP configuration or reads credentials.
10
10
 
@@ -32,11 +32,11 @@ Do not pass headers, bearer tokens or OAuth client ids: Pi discovers the OAuth e
32
32
 
33
33
  On older Pi versions without `pi mcp`, install `pi-mcp-adapter` (`pi install npm:pi-mcp-adapter`), define one server named `allye` (`{"type": "http", "url": "https://mcp.allye.app/mcp"}` under `mcpServers`) in the effective source shown by `/mcp` — for example `~/.pi/agent/mcp.json` or `~/.config/mcp/mcp.json` — then run `/mcp-auth allye` and `/mcp reconnect allye`.
34
34
 
35
- The extension preloads Allye context only when `pi-mcp-adapter` exposes its in-process bridge. Otherwise it injects only the git remote and any unverified link claim, and the bootstrap tells the agent to call `projects.project_resolve` itself.
35
+ The extension resolves the repository's project only when `pi-mcp-adapter` exposes its in-process bridge. Otherwise it injects only the git remote and any unverified link claim, and the bootstrap tells the agent to call `projects.project_resolve` itself.
36
36
 
37
37
  ## Teams
38
38
 
39
- The team follows the project: the extension never blocks work on team selection. A default team is needed only to create projects; `/allye-team <name|prefix|id>` sets it through the `team` tool (`team_set_default`). Set `ALLYE_PI_NATIVE_BOOTSTRAP=0` to skip the context preload (the bootstrap text and the Bridge skill stay available), or `ALLYE_PI_MCP=0` to disable MCP calls from the extension.
39
+ The team follows the project: the extension never blocks work on team selection. A default team is needed only to create projects; `/allye-team <name|prefix|id>` sets it through the `team` tool (`team_set_default`). Set `ALLYE_PI_NATIVE_BOOTSTRAP=0` to skip the repository block (the bootstrap text and the Bridge skill stay available), or `ALLYE_PI_MCP=0` to disable MCP calls from the extension.
40
40
 
41
41
  ## Local development
42
42
 
@@ -48,3 +48,11 @@ npm run typecheck
48
48
  npm run test:pi
49
49
  pi -e ./packages/allye-pi/src/index.ts -p "Which Allye tools and Bridge modes are available?"
50
50
  ```
51
+
52
+ ## Organization skills
53
+
54
+ `./install.sh install pi <skill>` (see the [README](../README.md#your-organizations-skills)) installs into `$PI_CODING_AGENT_DIR/skills` when `PI_CODING_AGENT_DIR` is set, otherwise `~/.pi/agent/skills`. The value must be an absolute path; only the empty string means unset, a trailing `/` is stripped, and a relative value stops the Pi install before any request. Changing it changes the install target; copies left in the old directory are not migrated. The API sees runtime `pi` and the skill is installed as a plain directory, with no package receipt.
55
+
56
+ - Edited copies: `./install.sh install pi <skill> --reinstall` replaces your edited copy and keeps it at `~/.allye/backups/pi/<skill>.allye.backup.<UTC ts>`. A copy of the same release on the same target is already installed (a no-op); older-release copies are updated, and copies recorded for another target (after a hostname or skills-path change) are reinstalled when unchanged, while an edited one needs `--reinstall`. Foreign or unmanaged folders are never touched.
57
+ - Leftovers: if the installer reports a `.allye-backup.<skill>.*` entry in the skills directory, an earlier run kept your edited folder there. Move it into `~/.allye/backups/pi/` or delete it, then rerun.
58
+ - Any member with view access to a skill can install it. If the installer is too old for the API, upgrade the installer; there is nothing to ask an admin to reset. Until the allye-api PROD release the installer works only against HML. See the [README](../README.md#your-organizations-skills).
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+ // docs-check: deterministic checks for a repository's git docs (no dependencies).
3
+ //
4
+ // node install/docs-check.mjs [--root <dir>] [file...]
5
+ //
6
+ // Files are relative to --root (default: the current directory). Without files it checks
7
+ // README.md, AGENTS.md, CLAUDE.md and docs/**/*.md under the root (regular files only).
8
+ // Rules, one line per finding `docs-check: <file>:<line>: <rule>: <message>`:
9
+ // link - relative link targets exist inside the root; symlinks are resolved before
10
+ // existence is reported, so an escaping or dangling one is "outside root"
11
+ // anchor - `#anchor` and `file.md#anchor` match a GitHub heading slug of the target
12
+ // (exact, then case-insensitive) or an <a name|id> anchor
13
+ // fence - backtick and tilde code fences (in list items or quotes too) are closed; a
14
+ // 4-space indented code block outside a list is code, not a fence
15
+ // lines - README.md <= 150, AGENTS.md <= 100, docs/ARCHITECTURE.md <= 300 lines; no
16
+ // line longer than 10000 characters (such a line is not parsed)
17
+ // URLs with a scheme (http(s), mailto, kb-page:, ...) and protocol-relative URLs are
18
+ // skipped, Allye URLs included. Fenced content and inline code are ignored for links and
19
+ // headings. Exit 0 clean, 1 findings, 2 usage or file system error.
20
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs';
21
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
22
+
23
+ const USAGE = 'usage: node install/docs-check.mjs [--root <dir>] [file...]';
24
+ const LIMITS = { 'README.md': 150, 'AGENTS.md': 100, 'docs/ARCHITECTURE.md': 300 };
25
+ const DEFAULT_FILES = ['README.md', 'AGENTS.md', 'CLAUDE.md'];
26
+ const MAX_LINE = 10000;
27
+
28
+ class UsageError extends Error {}
29
+
30
+ const toPosix = (p) => p.split(sep).join('/');
31
+ const outside = (rel) => rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
32
+
33
+ function decode(s) {
34
+ try {
35
+ return decodeURIComponent(s);
36
+ } catch {
37
+ return s;
38
+ }
39
+ }
40
+
41
+ function parseArgs(argv) {
42
+ let root = '.';
43
+ const files = [];
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a === '--root') {
47
+ if (i + 1 >= argv.length) throw new UsageError('--root needs a directory');
48
+ root = argv[++i];
49
+ } else if (a.startsWith('-')) throw new UsageError(`unknown option ${a}`);
50
+ else files.push(a);
51
+ }
52
+ return { root, files };
53
+ }
54
+
55
+ function walkMarkdown(absDir, out) {
56
+ for (const name of readdirSync(absDir).sort()) {
57
+ const abs = join(absDir, name);
58
+ const st = lstatSync(abs);
59
+ if (st.isDirectory()) walkMarkdown(abs, out);
60
+ else if (st.isFile() && name.endsWith('.md')) out.push(abs);
61
+ }
62
+ }
63
+
64
+ // Removes inline code spans (a backtick run closed by the next run of the same length).
65
+ function stripCode(line) {
66
+ const runs = [];
67
+ for (const m of line.matchAll(/`+/g)) runs.push({ start: m.index, len: m[0].length });
68
+ const next = new Array(runs.length).fill(-1);
69
+ const last = new Map();
70
+ for (let i = runs.length - 1; i >= 0; i--) {
71
+ next[i] = last.has(runs[i].len) ? last.get(runs[i].len) : -1;
72
+ last.set(runs[i].len, i);
73
+ }
74
+ let out = '';
75
+ let pos = 0;
76
+ for (let i = 0; i < runs.length; i++) {
77
+ if (runs[i].start < pos || next[i] === -1) continue;
78
+ const close = runs[next[i]];
79
+ out += line.slice(pos, runs[i].start);
80
+ pos = close.start + close.len;
81
+ i = next[i];
82
+ }
83
+ return out + line.slice(pos);
84
+ }
85
+
86
+ // nextOf(code, test)[i]: the first index >= i whose character passes test (n when none).
87
+ function nextOf(code, test) {
88
+ const n = code.length;
89
+ const out = new Int32Array(n + 2).fill(n);
90
+ for (let i = n - 1; i >= 0; i--) out[i] = test(code[i]) ? i : out[i + 1];
91
+ return out;
92
+ }
93
+
94
+ // Inline link destinations `](dest)`, `](dest(with)(parens))`, `](<dest with spaces>)`,
95
+ // `](dest "title")`. Linear: every lookup uses arrays precomputed in one pass, and a bare
96
+ // destination ends at the first whitespace or at the `)` that unbalances its parentheses
97
+ // (the next smaller prefix sum of +1 per `(` and -1 per `)`).
98
+ function inlineLinks(code) {
99
+ const targets = [];
100
+ if (!code.includes('](')) return targets;
101
+ const n = code.length;
102
+ const isWs = (c) => c === ' ' || c === '\t';
103
+ const nextWs = nextOf(code, isWs);
104
+ const nextNonWs = nextOf(code, (c) => !isWs(c));
105
+ const nextChar = {};
106
+ for (const c of ['>', '"', "'", ')']) nextChar[c] = nextOf(code, (x) => x === c);
107
+ const sum = new Int32Array(n + 1);
108
+ for (let i = 0; i < n; i++) sum[i + 1] = sum[i] + (code[i] === '(' ? 1 : code[i] === ')' ? -1 : 0);
109
+ const nextLower = new Int32Array(n + 1).fill(n + 1);
110
+ const stack = [];
111
+ for (let k = n; k >= 0; k--) {
112
+ while (stack.length > 0 && sum[stack[stack.length - 1]] >= sum[k]) stack.pop();
113
+ if (stack.length > 0) nextLower[k] = stack[stack.length - 1];
114
+ stack.push(k);
115
+ }
116
+ let from = 0;
117
+ for (;;) {
118
+ const at = code.indexOf('](', from);
119
+ if (at === -1) break;
120
+ from = at + 2;
121
+ let p = nextNonWs[at + 2];
122
+ let target;
123
+ if (code[p] === '<') {
124
+ const end = nextChar['>'][p + 1];
125
+ if (end >= n) continue;
126
+ target = code.slice(p + 1, end);
127
+ p = end + 1;
128
+ } else {
129
+ const stop = Math.min(nextWs[p], nextLower[p] - 1);
130
+ if (stop >= n) continue;
131
+ target = code.slice(p, stop);
132
+ p = stop;
133
+ }
134
+ p = nextNonWs[p];
135
+ const q = code[p];
136
+ if (q === '"' || q === "'" || q === '(') {
137
+ const end = nextChar[q === '(' ? ')' : q][p + 1];
138
+ if (end >= n) continue;
139
+ p = nextNonWs[end + 1];
140
+ }
141
+ if (code[p] === ')' && target !== '') {
142
+ targets.push(target);
143
+ from = p + 1;
144
+ }
145
+ }
146
+ return targets;
147
+ }
148
+
149
+ // ATX heading text, or null. Linear: the closing `#` sequence is removed by hand.
150
+ function atxHeading(line) {
151
+ const t = line.replace(/[ \t]+$/, '');
152
+ const m = /^ {0,3}(#{1,6})(?=[ \t]|$)/.exec(t);
153
+ if (!m) return null;
154
+ let text = t.slice(m[0].length).trim();
155
+ let j = text.length;
156
+ while (j > 0 && text[j - 1] === '#') j--;
157
+ if (j === 0) text = '';
158
+ else if (j < text.length && (text[j - 1] === ' ' || text[j - 1] === '\t')) text = text.slice(0, j).trim();
159
+ return text;
160
+ }
161
+
162
+ // Leading indentation in columns (a tab advances to the next multiple of 4).
163
+ function columns(line) {
164
+ let col = 0;
165
+ for (const c of line) {
166
+ if (c === ' ') col++;
167
+ else if (c === '\t') col += 4 - (col % 4);
168
+ else break;
169
+ }
170
+ return col;
171
+ }
172
+
173
+ // Removes `_` emphasis delimiters that pair up (CommonMark flanking rules, closer matched
174
+ // with the innermost opener of the same length); intraword and lone `_` are kept.
175
+ function stripUnderscoreEmphasis(t) {
176
+ const word = (c) => c !== '' && /[\p{L}\p{N}]/u.test(c);
177
+ const space = (c) => c === '' || /\s/.test(c);
178
+ const runs = [];
179
+ const stack = [];
180
+ for (const m of t.matchAll(/_+/g)) {
181
+ const start = m.index;
182
+ const end = start + m[0].length;
183
+ const before = start > 0 ? t[start - 1] : '';
184
+ const after = end < t.length ? t[end] : '';
185
+ const run = { start, end, len: end - start, matched: false };
186
+ runs.push(run);
187
+ const top = stack[stack.length - 1];
188
+ if (!space(before) && !word(after) && top && top.len === run.len) {
189
+ top.matched = true;
190
+ run.matched = true;
191
+ stack.pop();
192
+ } else if (!space(after) && !word(before)) stack.push(run);
193
+ }
194
+ let out = '';
195
+ let pos = 0;
196
+ for (const r of runs) {
197
+ if (!r.matched) continue;
198
+ out += t.slice(pos, r.start);
199
+ pos = r.end;
200
+ }
201
+ return out + t.slice(pos);
202
+ }
203
+
204
+ // Line-by-line scan: fences, headings, and links outside fences.
205
+ function scan(text) {
206
+ const lines = text.split('\n');
207
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
208
+ const headings = [];
209
+ const anchors = [];
210
+ const links = [];
211
+ const longLines = [];
212
+ let fence = null; // { char, len, line, marker }
213
+ let prev = null; // previous plain paragraph line (for setext headings)
214
+ let prevBlank = true;
215
+ let inList = false;
216
+ let inIndented = false; // inside a 4-space indented code block
217
+ for (let i = 0; i < lines.length; i++) {
218
+ const n = i + 1;
219
+ if (lines[i].length > MAX_LINE) {
220
+ longLines.push(n);
221
+ prev = null;
222
+ prevBlank = false;
223
+ continue;
224
+ }
225
+ const line = lines[i].replace(/\r$/, '').replace(/^(?: {0,3}> ?)+/, '');
226
+ const blank = line.trim() === '';
227
+ const wasBlank = prevBlank;
228
+ prevBlank = blank;
229
+ if (fence) {
230
+ const close = /^\s*(`{3,}|~{3,})\s*$/.exec(line);
231
+ if (close && close[1][0] === fence.char && close[1].length >= fence.len) fence = null;
232
+ continue;
233
+ }
234
+ const indent = columns(line);
235
+ if (inIndented && (blank || indent >= 4)) continue;
236
+ inIndented = false;
237
+ if (!blank && !inList && indent >= 4) {
238
+ // Outside a list, 4+ columns is an indented code block after a blank line, else a
239
+ // paragraph continuation; neither opens a fence.
240
+ if (wasBlank) {
241
+ inIndented = true;
242
+ prev = null;
243
+ continue;
244
+ }
245
+ } else if (!blank) {
246
+ if (/^[ \t]*(?:[-*+]|\d+[.)])(?:[ \t]|$)/.test(line)) inList = true;
247
+ else if (indent < 2) inList = false;
248
+ }
249
+ const open = inList || indent < 4 ? /^\s*(`{3,}|~{3,})(.*)$/.exec(line) : null;
250
+ if (open && !(open[1][0] === '`' && open[2].includes('`'))) {
251
+ fence = { char: open[1][0], len: open[1].length, line: n, marker: open[1] };
252
+ prev = null;
253
+ continue;
254
+ }
255
+ const heading = atxHeading(line);
256
+ if (heading !== null) {
257
+ headings.push(heading);
258
+ prev = null;
259
+ } else if (prev !== null && /^ {0,3}(?:=+|-+)[ \t]*$/.test(line)) {
260
+ headings.push(prev.trim());
261
+ prev = null;
262
+ continue;
263
+ } else if (line.trim() === '' || /^ {0,3}(?:[-*+>|]|\d+[.)])/.test(line)) {
264
+ prev = null;
265
+ } else {
266
+ prev = line;
267
+ }
268
+ // `<a ...>` tags, scanned linearly: each opener looks for the next `>` once, and none
269
+ // left means no later opener can close either.
270
+ const opener = /<a\s/gi;
271
+ for (let m; (m = opener.exec(line)); ) {
272
+ const end = line.indexOf('>', m.index);
273
+ if (end === -1) break;
274
+ const attr = /\b(?:name|id)\s*=\s*(?:"([^"]+)"|'([^']+)')/i.exec(line.slice(m.index, end + 1));
275
+ if (attr) anchors.push(attr[1] ?? attr[2]);
276
+ opener.lastIndex = end + 1;
277
+ }
278
+ const code = stripCode(line);
279
+ for (const target of inlineLinks(code)) links.push({ target, line: n });
280
+ const def = /^ {0,3}\[[^\]]+\]:[ \t]*(?:<([^>]*)>|(\S+))/.exec(code);
281
+ if (def) links.push({ target: def[1] ?? def[2], line: n });
282
+ }
283
+ return { lineCount: lines.length, headings, anchors, links, longLines, unclosed: fence };
284
+ }
285
+
286
+ // GitHub heading anchors (github-slugger): rendered text, lowercased, punctuation removed,
287
+ // spaces to hyphens; a taken slug gets -1, -2, ... until it is free. Computed once per file.
288
+ function slugs(doc) {
289
+ if (doc.slugs) return doc.slugs;
290
+ const occurrences = new Map();
291
+ for (const h of doc.headings) {
292
+ const text = stripUnderscoreEmphasis(
293
+ h
294
+ .replace(/!?\[([^[\]]*)\]\([^()]*\)/g, '$1')
295
+ .replace(/<[^<>]*>/g, '')
296
+ .replace(/`/g, ''),
297
+ );
298
+ const base = text
299
+ .toLowerCase()
300
+ .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, '')
301
+ .replace(/ /g, '-');
302
+ let slug = base;
303
+ while (occurrences.has(slug)) {
304
+ const count = occurrences.get(base) + 1;
305
+ occurrences.set(base, count);
306
+ slug = `${base}-${count}`;
307
+ }
308
+ occurrences.set(slug, 0);
309
+ }
310
+ const exact = new Set([...occurrences.keys(), ...doc.anchors]);
311
+ doc.slugs = { exact, lower: new Set([...exact].map((x) => x.toLowerCase())) };
312
+ return doc.slugs;
313
+ }
314
+
315
+ // Resolves every symlink of an absolute path, also when the path (or a link target) does
316
+ // not exist: { real, exists }. A symlink loop resolves to the filesystem root.
317
+ function resolveReal(p, depth = 0) {
318
+ if (depth > 40) return { real: resolve('/'), exists: false };
319
+ try {
320
+ return { real: realpathSync(p), exists: true };
321
+ } catch {
322
+ // fall through: p or one of its components does not resolve
323
+ }
324
+ const parent = dirname(p);
325
+ if (parent === p) return { real: p, exists: false };
326
+ let st = null;
327
+ try {
328
+ st = lstatSync(p);
329
+ } catch {
330
+ st = null;
331
+ }
332
+ if (st && st.isSymbolicLink()) {
333
+ return { real: resolveReal(resolve(dirname(p), readlinkSync(p)), depth + 1).real, exists: false };
334
+ }
335
+ return { real: join(resolveReal(parent, depth + 1).real, basename(p)), exists: false };
336
+ }
337
+
338
+ function main() {
339
+ const { root, files } = parseArgs(process.argv.slice(2));
340
+ const absRoot = resolve(root);
341
+ if (!existsSync(absRoot) || !statSync(absRoot).isDirectory()) throw new UsageError(`root is not a directory: ${root}`);
342
+ const realRoot = realpathSync(absRoot);
343
+ const escapes = (abs) => outside(relative(realRoot, realpathSync(abs)));
344
+
345
+ const targets = [];
346
+ if (files.length > 0) {
347
+ for (const f of files) {
348
+ const abs = resolve(absRoot, f);
349
+ if (outside(relative(absRoot, abs))) throw new UsageError(`file outside root: ${f}`);
350
+ if (!existsSync(abs) || !statSync(abs).isFile()) throw new UsageError(`no such file: ${f}`);
351
+ if (escapes(abs)) throw new UsageError(`file outside root: ${f}`);
352
+ targets.push(abs);
353
+ }
354
+ } else {
355
+ for (const f of DEFAULT_FILES) {
356
+ const abs = join(absRoot, f);
357
+ if (existsSync(abs) && lstatSync(abs).isFile()) targets.push(abs);
358
+ }
359
+ const docs = join(absRoot, 'docs');
360
+ if (existsSync(docs) && lstatSync(docs).isDirectory()) walkMarkdown(docs, targets);
361
+ }
362
+
363
+ // Keyed by real path: every read goes through the resolved path, never a symlink.
364
+ const cache = new Map();
365
+ const load = (real) => {
366
+ if (!cache.has(real)) cache.set(real, scan(readFileSync(real, 'utf8')));
367
+ return cache.get(real);
368
+ };
369
+ const findings = [];
370
+ for (const abs of targets) {
371
+ const rel = toPosix(relative(absRoot, abs));
372
+ const selfReal = realpathSync(abs);
373
+ const doc = load(selfReal);
374
+ const own = [];
375
+ const report = (line, rule, message) => own.push({ line, text: `docs-check: ${rel}:${line}: ${rule}: ${message}` });
376
+ for (const { target, line } of doc.links) {
377
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//')) continue;
378
+ const hash = target.indexOf('#');
379
+ const beforeHash = hash === -1 ? target : target.slice(0, hash);
380
+ const anchor = hash === -1 ? '' : decode(target.slice(hash + 1));
381
+ const query = beforeHash.indexOf('?');
382
+ const pathPart = decode(query === -1 ? beforeHash : beforeHash.slice(0, query));
383
+ let dest = abs;
384
+ let real = selfReal;
385
+ if (pathPart !== '') {
386
+ dest = pathPart.startsWith('/') ? join(absRoot, pathPart) : resolve(dirname(abs), pathPart);
387
+ if (outside(relative(absRoot, dest))) {
388
+ report(line, 'link', `target outside root ${target}`);
389
+ continue;
390
+ }
391
+ // Containment before existence: a dangling or escaping symlink is "outside root".
392
+ const r = resolveReal(dest);
393
+ if (outside(relative(realRoot, r.real))) {
394
+ report(line, 'link', `target outside root ${target}`);
395
+ continue;
396
+ }
397
+ if (!r.exists) {
398
+ report(line, 'link', `missing target ${target}`);
399
+ continue;
400
+ }
401
+ real = r.real;
402
+ }
403
+ if (anchor === '' || !dest.endsWith('.md') || !statSync(real).isFile()) continue;
404
+ const known = slugs(load(real));
405
+ if (!known.exact.has(anchor) && !known.lower.has(anchor.toLowerCase())) {
406
+ report(line, 'anchor', `no heading for #${anchor} in ${toPosix(relative(absRoot, dest))}`);
407
+ }
408
+ }
409
+ if (doc.unclosed) report(doc.unclosed.line, 'fence', `unclosed ${doc.unclosed.marker} fence`);
410
+ for (const n of doc.longLines) report(n, 'lines', `line longer than ${MAX_LINE} characters`);
411
+ const limit = LIMITS[rel];
412
+ if (limit !== undefined && doc.lineCount > limit) report(limit + 1, 'lines', `${doc.lineCount} lines, limit ${limit}`);
413
+ own.sort((a, b) => a.line - b.line); // stable: a file's findings in line order
414
+ findings.push(...own.map((f) => f.text));
415
+ }
416
+
417
+ if (findings.length > 0) {
418
+ process.stdout.write(`${findings.join('\n')}\n`);
419
+ process.stderr.write(`docs-check: ${findings.length} finding(s)\n`);
420
+ process.exit(1);
421
+ }
422
+ process.stdout.write(`docs-check: ok (${targets.length} files)\n`);
423
+ }
424
+
425
+ try {
426
+ main();
427
+ } catch (err) {
428
+ if (err instanceof UsageError) {
429
+ process.stderr.write(`docs-check: ${err.message}\ndocs-check: ${USAGE}\n`);
430
+ } else {
431
+ process.stderr.write(`docs-check: error: ${err.message}\n`);
432
+ }
433
+ process.exit(2);
434
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "allye-pi",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "description": "Allye extension for Pi and OMP: the Allye bootstrap, the Bridge skill and the repository-to-project link",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -15,6 +15,8 @@
15
15
  "packages/allye-pi/tsconfig.json",
16
16
  "skills",
17
17
  "bootstrap",
18
+ "templates/git-docs",
19
+ "install/docs-check.mjs",
18
20
  "docs/install-pi.md",
19
21
  "docs/install-omp.md",
20
22
  "LICENSE"
@@ -43,11 +45,13 @@
43
45
  "scripts": {
44
46
  "typecheck": "tsc --noEmit -p packages/allye-pi/tsconfig.json",
45
47
  "test:pi": "node --experimental-strip-types --test packages/allye-pi/src/index.test.ts",
46
- "test:installer": "bash install/test-validation.sh && bash install/test-installer.sh",
48
+ "test:opencode": "node --experimental-strip-types --test packages/allye-opencode/src/skills-paths.test.ts",
49
+ "test:installer": "bash install/test-validation.sh && bash install/test-installer.sh && npm run test:docs-gate",
47
50
  "test:bootstrap": "bash test/test-bootstrap.sh && bash hooks/test-session-start.sh",
48
51
  "test:agents": "node test/test-agents.mjs",
49
- "test:skills": "node test/test-skills-text.mjs && node test/test-ci-proof.mjs",
50
- "test": "npm run test:bootstrap && npm run test:agents && npm run test:skills && npm run test:installer && npm run test:pi"
52
+ "test:docs-gate": "bash install/test-docs-gate.sh",
53
+ "test:skills": "node test/test-skills-text.mjs && node test/test-ci-proof.mjs && node test/test-ci-workflows.mjs",
54
+ "test": "npm run test:bootstrap && npm run test:agents && npm run test:skills && npm run test:installer && npm run test:pi && npm run test:opencode"
51
55
  },
52
56
  "repository": {
53
57
  "type": "git",