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 +21 -0
- package/README.md +10 -5
- package/docs/install-omp.md +9 -1
- package/docs/install-pi.md +11 -3
- package/install/docs-check.mjs +434 -0
- package/package.json +8 -4
- package/packages/allye-pi/src/index.ts +11 -71
- package/skills/bridge/SKILL.md +16 -0
- package/skills/bridge/references/crew.md +1 -0
- package/skills/bridge/references/delegation.md +8 -4
- package/skills/bridge/references/publish.md +10 -0
- package/skills/bridge/references/spec-lint.md +2 -0
- package/skills/bridge/references/state-contract.md +14 -9
- package/skills/bridge/references/workflow.md +1 -1
- package/skills/bridge-architect/SKILL.md +3 -0
- package/skills/bridge-architect/references/spec-template.md +7 -0
- package/skills/bridge-armorer/SKILL.md +2 -2
- package/skills/bridge-copilot/SKILL.md +4 -1
- package/skills/bridge-dispatcher/SKILL.md +31 -4
- package/skills/bridge-dispatcher/references/spec-template-minimal.md +8 -0
- package/skills/bridge-navigator/SKILL.md +93 -0
- package/skills/bridge-pilot/SKILL.md +2 -0
- package/skills/bridge-shield/SKILL.md +8 -0
- package/skills/bridge-strategist/SKILL.md +6 -0
- package/skills/bridge-watcher/SKILL.md +19 -0
- package/templates/git-docs/AGENTS.md +33 -0
- package/templates/git-docs/ARCHITECTURE.md +24 -0
- package/templates/git-docs/CLAUDE.md +6 -0
- package/templates/git-docs/README.md +41 -0
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.
|
|
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
|
|
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).
|
package/docs/install-omp.md
CHANGED
|
@@ -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
|
|
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).
|
package/docs/install-pi.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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
|
|
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.
|
|
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:
|
|
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:
|
|
50
|
-
"test": "
|
|
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",
|