rahman-resources 1.13.2 → 1.14.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/bin/cli.js +45 -6
- package/bin/migrate-load.mjs +15 -8
- package/bin/migrate.mjs +1 -1
- package/lib/compose-solver-loader.mjs +14 -40
- package/lib/compose-solver-resolve.mjs +3 -3
- package/lib/contract-tools.test.ts +52 -0
- package/lib/contract-types.ts +8 -0
- package/lib/contract-validate.ts +23 -1
- package/lib/contract.ts +2 -0
- package/lib/load-contract.mjs +33 -0
- package/lib/manifest.json +396 -1414
- package/lib/slice-schema.json +8 -0
- package/lib/snapshot.mjs +11 -57
- package/lib/starter/_README.md +29 -6
- package/package.json +4 -4
package/bin/cli.js
CHANGED
|
@@ -869,8 +869,12 @@ async function installSkill(slug, target) {
|
|
|
869
869
|
const dest = path.join(target, ".claude", "skills", slug);
|
|
870
870
|
process.stdout.write(` ${kleur.cyan(slug.padEnd(20))} ${kleur.dim(`→ .claude/skills/${slug}/`)} ... `);
|
|
871
871
|
if (skill.source === "anthropics") {
|
|
872
|
-
const
|
|
873
|
-
await
|
|
872
|
+
const source = `${SKILLS_REPO}/${skill.path}`;
|
|
873
|
+
await cloneWithRetry(
|
|
874
|
+
() => tiged(source, { cache: false, force: true, verbose: false }),
|
|
875
|
+
dest,
|
|
876
|
+
source,
|
|
877
|
+
);
|
|
874
878
|
} else if (skill.source === "rahman") {
|
|
875
879
|
// Future: ship rahman-authored skills inside this repo. For now, scaffold a stub.
|
|
876
880
|
mkdirSync(dest, { recursive: true });
|
|
@@ -1372,14 +1376,49 @@ async function checkGhInstalled() {
|
|
|
1372
1376
|
|
|
1373
1377
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
|
1374
1378
|
|
|
1379
|
+
// tiged surfaces raw git/network errors with no recovery hint. Translate the
|
|
1380
|
+
// common failure modes into actionable messages, and retry once on transient
|
|
1381
|
+
// network errors before giving up.
|
|
1382
|
+
async function cloneWithRetry(makeEmitter, dest, source) {
|
|
1383
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
1384
|
+
try {
|
|
1385
|
+
await makeEmitter().clone(dest);
|
|
1386
|
+
return;
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
const msg = String(err?.message ?? err);
|
|
1389
|
+
const transient = /ETIMEDOUT|ECONNRESET|EAI_AGAIN|socket hang up|50[234]/i.test(msg);
|
|
1390
|
+
if (transient && attempt === 1) continue; // one silent retry
|
|
1391
|
+
if (/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ECONNRESET/i.test(msg)) {
|
|
1392
|
+
throw new Error(
|
|
1393
|
+
`Network error fetching ${source}: ${msg}\n → check your internet connection and retry.`,
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
if (/could not find|not found|ENOENT|404/i.test(msg)) {
|
|
1397
|
+
throw new Error(
|
|
1398
|
+
`Could not fetch ${source}: ${msg}\n → verify the slug exists (run 'list') and the repo/branch is public.`,
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
throw new Error(`Failed to fetch ${source}: ${msg}`);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1375
1406
|
async function pull(repoPath, dest) {
|
|
1376
|
-
const
|
|
1377
|
-
await
|
|
1407
|
+
const source = `${REPO}/${repoPath}#${BRANCH}`;
|
|
1408
|
+
await cloneWithRetry(
|
|
1409
|
+
() => tiged(source, { cache: false, force: true, verbose: false }),
|
|
1410
|
+
dest,
|
|
1411
|
+
source,
|
|
1412
|
+
);
|
|
1378
1413
|
}
|
|
1379
1414
|
|
|
1380
1415
|
async function pullFromRepo(repo, subPath, branch, dest) {
|
|
1381
|
-
const
|
|
1382
|
-
await
|
|
1416
|
+
const source = `${repo}/${subPath}#${branch}`;
|
|
1417
|
+
await cloneWithRetry(
|
|
1418
|
+
() => tiged(source, { cache: false, force: true, verbose: false }),
|
|
1419
|
+
dest,
|
|
1420
|
+
source,
|
|
1421
|
+
);
|
|
1383
1422
|
}
|
|
1384
1423
|
|
|
1385
1424
|
function detectPM(target) {
|
package/bin/migrate-load.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import path from "node:path";
|
|
11
|
+
import { loadSliceContract } from "../lib/load-contract.mjs";
|
|
11
12
|
|
|
12
13
|
export function parseFlags(rest) {
|
|
13
14
|
const positional = [];
|
|
@@ -51,26 +52,32 @@ const SLICE_ROOTS = [
|
|
|
51
52
|
["template-base", "frontend", "slices"],
|
|
52
53
|
];
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
// The CURRENT contract is folded into slice.json — locate the slice dir by its
|
|
56
|
+
// slice.json (the .ts no longer exists in the working tree post-cutover).
|
|
57
|
+
function resolveSliceDir(repoRoot, slug) {
|
|
55
58
|
for (const segs of SLICE_ROOTS) {
|
|
56
|
-
const
|
|
57
|
-
if (existsSync(
|
|
59
|
+
const dir = path.join(repoRoot, ...segs, slug);
|
|
60
|
+
if (existsSync(path.join(dir, "slice.json"))) return dir;
|
|
58
61
|
}
|
|
59
62
|
return null;
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
// HISTORIC leg still pulls the OLD slice.contract.ts from git history, so the
|
|
66
|
+
// rel path is the .ts path — but gate on the slice's slice.json existing today
|
|
67
|
+
// (NOT the working-tree .ts, which is gone post-cutover).
|
|
62
68
|
function resolveContractRelPath(repoRoot, slug) {
|
|
63
69
|
for (const segs of SLICE_ROOTS) {
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
if (existsSync(path.join(repoRoot, ...segs, slug, "slice.json"))) {
|
|
71
|
+
return [...segs, slug, "slice.contract.ts"].join("/");
|
|
72
|
+
}
|
|
66
73
|
}
|
|
67
74
|
return null;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
export function loadCurrentContract(repoRoot, slug) {
|
|
71
|
-
const
|
|
72
|
-
if (!
|
|
73
|
-
return
|
|
78
|
+
const dir = resolveSliceDir(repoRoot, slug);
|
|
79
|
+
if (!dir) return null;
|
|
80
|
+
return loadSliceContract(dir);
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
/**
|
package/bin/migrate.mjs
CHANGED
|
@@ -80,7 +80,7 @@ export async function runMigrate(rest) {
|
|
|
80
80
|
process.stderr.write(
|
|
81
81
|
kleur.red(
|
|
82
82
|
`migrate: cannot load current contract for "${slug}". ` +
|
|
83
|
-
`Expected frontend/slices/${slug}/slice.
|
|
83
|
+
`Expected a contract block in frontend/slices/${slug}/slice.json (or template-base/frontend/slices/${slug}/slice.json).\n`,
|
|
84
84
|
),
|
|
85
85
|
);
|
|
86
86
|
process.exit(1);
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// compose-solver-loader.mjs — contract discovery
|
|
1
|
+
// compose-solver-loader.mjs — contract discovery loader.
|
|
2
2
|
//
|
|
3
3
|
// Split out from compose-solver.mjs to keep the pure solver < 200 LOC.
|
|
4
|
-
// I/O entry point only — discovers
|
|
5
|
-
//
|
|
4
|
+
// I/O entry point only — discovers slices under known roots and reads each
|
|
5
|
+
// folded contract from slice.json (`contract` block) via the shared adapter.
|
|
6
|
+
// No tsx subprocess: the contract lives in slice.json since the Phase-2 fold.
|
|
6
7
|
|
|
7
8
|
import { readdir } from "node:fs/promises";
|
|
8
9
|
import { existsSync } from "node:fs";
|
|
9
|
-
import { spawnSync } from "node:child_process";
|
|
10
10
|
import path from "node:path";
|
|
11
|
+
import { loadSliceContract } from "./load-contract.mjs";
|
|
11
12
|
|
|
12
13
|
const SLICE_ROOT_GLOBS = [
|
|
13
14
|
["frontend", "slices"],
|
|
@@ -15,11 +16,10 @@ const SLICE_ROOT_GLOBS = [
|
|
|
15
16
|
];
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
* Discover every
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* down the
|
|
22
|
-
* those errors.
|
|
19
|
+
* Discover every slice with a folded contract under the kitab's known slice
|
|
20
|
+
* roots. Returns a Map<slug, SliceContract>. Slices without a slice.json
|
|
21
|
+
* contract block are silently skipped so a single uncontracted slice doesn't
|
|
22
|
+
* take down the solver — `npm run audit:slices` surfaces shape errors.
|
|
23
23
|
*
|
|
24
24
|
* @param {string} repoRoot Absolute path to the kitab repo root.
|
|
25
25
|
* @returns {Promise<Map<string, import("./contract").SliceContract>>}
|
|
@@ -27,9 +27,8 @@ const SLICE_ROOT_GLOBS = [
|
|
|
27
27
|
export async function loadAllContracts(repoRoot) {
|
|
28
28
|
/** @type {Map<string, import("./contract").SliceContract>} */
|
|
29
29
|
const out = new Map();
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const contract = loadContractFile(repoRoot, filePath);
|
|
30
|
+
for (const dir of await discoverSliceDirs(repoRoot)) {
|
|
31
|
+
const contract = loadSliceContract(dir);
|
|
33
32
|
if (contract && typeof contract.id === "string") {
|
|
34
33
|
out.set(contract.id, contract);
|
|
35
34
|
}
|
|
@@ -37,7 +36,7 @@ export async function loadAllContracts(repoRoot) {
|
|
|
37
36
|
return out;
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
async function
|
|
39
|
+
async function discoverSliceDirs(repoRoot) {
|
|
41
40
|
const found = [];
|
|
42
41
|
for (const segs of SLICE_ROOT_GLOBS) {
|
|
43
42
|
const root = path.join(repoRoot, ...segs);
|
|
@@ -46,34 +45,9 @@ async function discoverContractFiles(repoRoot) {
|
|
|
46
45
|
for (const entry of entries) {
|
|
47
46
|
if (!entry.isDirectory()) continue;
|
|
48
47
|
if (entry.name.startsWith("_")) continue;
|
|
49
|
-
const
|
|
50
|
-
if (existsSync(
|
|
48
|
+
const dir = path.join(root, entry.name);
|
|
49
|
+
if (existsSync(path.join(dir, "slice.json"))) found.push(dir);
|
|
51
50
|
}
|
|
52
51
|
}
|
|
53
52
|
return found;
|
|
54
53
|
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Dynamic-import a .ts contract file via `npx tsx -e` and JSON.parse the
|
|
58
|
-
* stringified `contract` export. Returns null on any failure.
|
|
59
|
-
*/
|
|
60
|
-
function loadContractFile(repoRoot, filePath) {
|
|
61
|
-
const rel = "./" + path.relative(repoRoot, filePath);
|
|
62
|
-
const code = [
|
|
63
|
-
`import(${JSON.stringify(rel)})`,
|
|
64
|
-
` .then(m => { const c = m.contract || (m.default && m.default.contract); if (!c) { process.exit(2); } process.stdout.write(JSON.stringify(c)); })`,
|
|
65
|
-
` .catch(() => process.exit(3));`,
|
|
66
|
-
].join("\n");
|
|
67
|
-
const res = spawnSync("npx", ["--no-install", "tsx", "-e", code], {
|
|
68
|
-
cwd: repoRoot,
|
|
69
|
-
encoding: "utf8",
|
|
70
|
-
});
|
|
71
|
-
if (res.status === 0 && res.stdout) {
|
|
72
|
-
try {
|
|
73
|
-
return JSON.parse(res.stdout);
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
@@ -32,7 +32,7 @@ export function buildInitialCandidates({
|
|
|
32
32
|
{
|
|
33
33
|
type: "uncontracted",
|
|
34
34
|
slug,
|
|
35
|
-
detail: `Slice "${slug}" has no registered
|
|
35
|
+
detail: `Slice "${slug}" has no registered contract — accepted under allowUnknownSlices, but conflict checks are skipped for it.`,
|
|
36
36
|
severity: "warning",
|
|
37
37
|
},
|
|
38
38
|
slug,
|
|
@@ -41,13 +41,13 @@ export function buildInitialCandidates({
|
|
|
41
41
|
candidateSet.add(slug);
|
|
42
42
|
candidateOrder.push(slug);
|
|
43
43
|
notes.set(slug, "uncontracted");
|
|
44
|
-
proof.push(`! ${slug}: accepted as uncontracted (no slice.
|
|
44
|
+
proof.push(`! ${slug}: accepted as uncontracted (no contract (slice.json); skipping conflict checks)`);
|
|
45
45
|
} else {
|
|
46
46
|
record(
|
|
47
47
|
{
|
|
48
48
|
type: "missing-dep",
|
|
49
49
|
slug,
|
|
50
|
-
detail: `Contract not found for "${slug}" — no
|
|
50
|
+
detail: `Contract not found for "${slug}" — no registered contract (strict mode).`,
|
|
51
51
|
severity: "blocker",
|
|
52
52
|
},
|
|
53
53
|
slug,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { defineSliceContract } from "./contract";
|
|
3
|
+
|
|
4
|
+
const base = {
|
|
5
|
+
id: "foo",
|
|
6
|
+
version: "1.0.0",
|
|
7
|
+
requires: {},
|
|
8
|
+
provides: {},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("defineSliceContract — provides.tools (agentic surface)", () => {
|
|
12
|
+
it("accepts tool names prefixed with the slice id", () => {
|
|
13
|
+
expect(() =>
|
|
14
|
+
defineSliceContract({
|
|
15
|
+
...base,
|
|
16
|
+
provides: { tools: ["foo.bar", "foo.baz.qux"] },
|
|
17
|
+
}),
|
|
18
|
+
).not.toThrow();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("rejects tool names not prefixed with the slice id", () => {
|
|
22
|
+
expect(() =>
|
|
23
|
+
defineSliceContract({
|
|
24
|
+
...base,
|
|
25
|
+
provides: { tools: ["bar.baz"] },
|
|
26
|
+
}),
|
|
27
|
+
).toThrow(/must be prefixed with "foo\."/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects non-string / empty tool entries", () => {
|
|
31
|
+
expect(() =>
|
|
32
|
+
defineSliceContract({
|
|
33
|
+
...base,
|
|
34
|
+
provides: { tools: [""] as string[] },
|
|
35
|
+
}),
|
|
36
|
+
).toThrow(/non-empty strings/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("allows tools to be omitted (non-agentic slices)", () => {
|
|
40
|
+
expect(() => defineSliceContract({ ...base })).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("allows a conflict to reference a tool name", () => {
|
|
44
|
+
expect(() =>
|
|
45
|
+
defineSliceContract({
|
|
46
|
+
...base,
|
|
47
|
+
provides: { tools: ["foo.bar"] },
|
|
48
|
+
conflicts: ["other:tools.foo.bar"],
|
|
49
|
+
}),
|
|
50
|
+
).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
});
|
package/lib/contract-types.ts
CHANGED
|
@@ -88,6 +88,14 @@ export interface SliceContractProvides {
|
|
|
88
88
|
events?: string[];
|
|
89
89
|
/** Public component exports — e.g. `["DokuCheckoutButton"]`. */
|
|
90
90
|
components?: string[];
|
|
91
|
+
/**
|
|
92
|
+
* Fully-qualified AI tool names the slice exposes as a `ToolCollection`
|
|
93
|
+
* (`@/shared/agentic`) — e.g. `["image-editor.layer.add"]`. Every entry
|
|
94
|
+
* MUST be prefixed with the slice id so one agent can aggregate tools from
|
|
95
|
+
* many slices without collision. A slice is "agentic-ready" iff this is
|
|
96
|
+
* non-empty.
|
|
97
|
+
*/
|
|
98
|
+
tools?: string[];
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
// ---------------------------------------------------------------------------
|
package/lib/contract-validate.ts
CHANGED
|
@@ -18,7 +18,7 @@ const KEBAB_CASE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
|
18
18
|
const SEMVER =
|
|
19
19
|
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
20
20
|
const PREFIX = /^[a-z][a-z0-9_]*_$/;
|
|
21
|
-
const CONFLICT = /^[a-z][a-z0-9-]*:(routes|hooks|tables|events|components)\.[A-Za-z0-9_
|
|
21
|
+
const CONFLICT = /^[a-z][a-z0-9-]*:(routes|hooks|tables|events|components|tools)\.[A-Za-z0-9_\-\/.]+$/;
|
|
22
22
|
const PERMISSION = /^[^.]+\.[^.]+$/;
|
|
23
23
|
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
@@ -131,6 +131,28 @@ export function validateGeneralization(c: SliceContract): void {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
export function validateTools(c: SliceContract): void {
|
|
135
|
+
const tools = c.provides.tools;
|
|
136
|
+
if (tools === undefined) return;
|
|
137
|
+
if (!Array.isArray(tools)) {
|
|
138
|
+
throw new Error(`defineSliceContract(${c.id}): provides.tools must be an array`);
|
|
139
|
+
}
|
|
140
|
+
for (const t of tools) {
|
|
141
|
+
if (typeof t !== "string" || t.length === 0) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`defineSliceContract(${c.id}): provides.tools entries must be non-empty strings`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
// Every tool name MUST be namespaced to the slice id so one agent can
|
|
147
|
+
// aggregate collections from many slices without collision.
|
|
148
|
+
if (!t.startsWith(`${c.id}.`)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`defineSliceContract(${c.id}): provides.tools entry "${t}" must be prefixed with "${c.id}."`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
134
156
|
export function validateConflicts(c: SliceContract): void {
|
|
135
157
|
if (!c.conflicts) return;
|
|
136
158
|
if (!Array.isArray(c.conflicts)) {
|
package/lib/contract.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
validateConvex,
|
|
16
16
|
validateGeneralization,
|
|
17
17
|
validateConflicts,
|
|
18
|
+
validateTools,
|
|
18
19
|
} from "./contract-validate";
|
|
19
20
|
|
|
20
21
|
// Re-export the full type surface so existing imports keep working.
|
|
@@ -62,5 +63,6 @@ export function defineSliceContract(c: SliceContract): SliceContract {
|
|
|
62
63
|
validateConvex(c);
|
|
63
64
|
validateGeneralization(c);
|
|
64
65
|
validateConflicts(c);
|
|
66
|
+
validateTools(c);
|
|
65
67
|
return c;
|
|
66
68
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// load-contract.mjs — read a slice's folded composition contract from its
|
|
2
|
+
// slice.json (Phase 2 SSOT). Replaces parsing slice.contract.ts: the contract
|
|
3
|
+
// payload now lives under slice.json `contract`, with id/version omitted there
|
|
4
|
+
// (they are the slice.json scalars). This adapter reattaches them so callers
|
|
5
|
+
// get the same SliceContract shape the .ts used to export.
|
|
6
|
+
//
|
|
7
|
+
// Returns undefined when the slice has no slice.json or no contract block —
|
|
8
|
+
// callers treat that exactly like a missing slice.contract.ts before.
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} sliceDir Absolute path to frontend/slices/<slug>/.
|
|
15
|
+
* @returns {object|undefined} { id, version, requires?, provides?, conflicts?,
|
|
16
|
+
* migrationFrom?, generalization? } or undefined.
|
|
17
|
+
*/
|
|
18
|
+
export function loadSliceContract(sliceDir) {
|
|
19
|
+
return contractFromJson(path.join(sliceDir, "slice.json"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Same, given a direct path to a slice.json file. */
|
|
23
|
+
export function contractFromJson(jsonPath) {
|
|
24
|
+
if (!existsSync(jsonPath)) return undefined;
|
|
25
|
+
let j;
|
|
26
|
+
try {
|
|
27
|
+
j = JSON.parse(readFileSync(jsonPath, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if (!j.contract || typeof j.contract !== "object") return undefined;
|
|
32
|
+
return { id: j.slug, version: j.version, ...j.contract };
|
|
33
|
+
}
|