@vimhead.dev/norn-cli 0.1.0-tip.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/README.md +13 -0
- package/assets/README.md +157 -0
- package/assets/docs/README.md +23 -0
- package/assets/docs/agents.md +64 -0
- package/assets/docs/cli.md +183 -0
- package/assets/docs/composition.md +76 -0
- package/assets/docs/persistence.md +56 -0
- package/assets/docs/projects.md +75 -0
- package/assets/docs/recovery.md +56 -0
- package/assets/docs/resources.md +61 -0
- package/assets/docs/workflows.md +57 -0
- package/assets/examples/agent-then-analysis/README.md +103 -0
- package/assets/examples/agent-then-analysis/input.json +5 -0
- package/assets/examples/agent-then-analysis/norn.project.json +4 -0
- package/assets/examples/agent-then-analysis/plugin.ts +89 -0
- package/assets/examples/coordinating-multiple-agents/README.md +56 -0
- package/assets/examples/coordinating-multiple-agents/input.json +10 -0
- package/assets/examples/coordinating-multiple-agents/norn.project.json +4 -0
- package/assets/examples/coordinating-multiple-agents/plugin.ts +102 -0
- package/assets/examples/coordinating-multiple-agents/queue-adapter.ts +52 -0
- package/assets/examples/coordinating-multiple-agents/work-queue.ts +153 -0
- package/assets/examples/minimal-workflow/README.md +71 -0
- package/assets/examples/minimal-workflow/norn.project.json +4 -0
- package/assets/examples/minimal-workflow/plugin.ts +29 -0
- package/assets/examples/shared-state/README.md +19 -0
- package/assets/examples/shared-state/input.json +1 -0
- package/assets/examples/shared-state/norn.project.json +4 -0
- package/assets/examples/shared-state/plugin.ts +47 -0
- package/assets/examples/worktree-development-loop/README.md +66 -0
- package/assets/examples/worktree-development-loop/index.ts +1 -0
- package/assets/examples/worktree-development-loop/manifest.ts +26 -0
- package/assets/examples/worktree-development-loop/norn.project.json +9 -0
- package/assets/examples/worktree-development-loop/plugin.ts +27 -0
- package/assets/examples/worktree-development-loop/shared/commands.ts +6 -0
- package/assets/examples/worktree-development-loop/state.ts +23 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +18 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +4 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +22 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +14 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +54 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +3 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/schema.ts +12 -0
- package/assets/examples/worktree-development-loop/workflows/planning/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +28 -0
- package/assets/examples/worktree-development-loop/workflows/planning/index.ts +3 -0
- package/assets/examples/worktree-development-loop/workflows/planning/schema.ts +12 -0
- package/assets/examples/worktree-development-loop/workflows/review/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/review/execute.ts +53 -0
- package/assets/examples/worktree-development-loop/workflows/review/index.ts +10 -0
- package/assets/examples/worktree-development-loop/workflows/review/schema.ts +23 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/declaration.ts +12 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +51 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/index.ts +3 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/schema.ts +12 -0
- package/assets/package.json +1 -0
- package/assets/packages/cli/src/build-info.ts +36 -0
- package/assets/packages/cli/src/bun/cli.ts +16 -0
- package/assets/packages/cli/src/cli.ts +1135 -0
- package/assets/packages/cli/src/client.ts +167 -0
- package/assets/packages/cli/src/documentation-intro.ts +30 -0
- package/assets/packages/cli/src/documentation.ts +149 -0
- package/assets/packages/cli/src/generated-build-info.ts +12 -0
- package/assets/packages/cli/src/internal/agent-directory.ts +5 -0
- package/assets/packages/cli/src/internal/agent-response-tool.ts +96 -0
- package/assets/packages/cli/src/internal/agents.ts +365 -0
- package/assets/packages/cli/src/internal/artifacts.ts +26 -0
- package/assets/packages/cli/src/internal/commands.ts +180 -0
- package/assets/packages/cli/src/internal/documentation-bundle.ts +49 -0
- package/assets/packages/cli/src/internal/engine.ts +501 -0
- package/assets/packages/cli/src/internal/errors.ts +39 -0
- package/assets/packages/cli/src/internal/file-names.ts +3 -0
- package/assets/packages/cli/src/internal/launch-request.ts +94 -0
- package/assets/packages/cli/src/internal/logs.ts +41 -0
- package/assets/packages/cli/src/internal/metrics.ts +356 -0
- package/assets/packages/cli/src/internal/pi-assets.ts +95 -0
- package/assets/packages/cli/src/internal/resource-bindings.ts +35 -0
- package/assets/packages/cli/src/internal/run-lease.ts +158 -0
- package/assets/packages/cli/src/internal/run-log.ts +59 -0
- package/assets/packages/cli/src/internal/run-names.ts +36 -0
- package/assets/packages/cli/src/internal/run-resources.ts +23 -0
- package/assets/packages/cli/src/internal/run-state.ts +380 -0
- package/assets/packages/cli/src/internal/run-store.ts +323 -0
- package/assets/packages/cli/src/internal/run.ts +133 -0
- package/assets/packages/cli/src/internal/state-store.ts +75 -0
- package/assets/packages/cli/src/internal/usage.ts +70 -0
- package/assets/packages/cli/src/internal/workflow-registry.ts +176 -0
- package/assets/packages/cli/src/plugin-loader.ts +412 -0
- package/assets/packages/cli/src/resources.ts +67 -0
- package/assets/packages/core/src/agent-protocol.ts +1 -0
- package/assets/packages/core/src/atomic-files.ts +24 -0
- package/assets/packages/core/src/errors.ts +3 -0
- package/assets/packages/sdk/src/agent-resource-adapter.ts +11 -0
- package/assets/packages/sdk/src/api.ts +821 -0
- package/assets/packages/sdk/src/files.ts +136 -0
- package/assets/packages/sdk/src/index.ts +6 -0
- package/assets/packages/sdk/src/resources.ts +20 -0
- package/assets/packages/sdk/src/schema.ts +48 -0
- package/assets/packages/sdk/src/seer/config.ts +62 -0
- package/assets/packages/sdk/src/seer/index.ts +7 -0
- package/assets/packages/sdk/src/state-adapter.ts +75 -0
- package/assets/setup/providers.md +128 -0
- package/assets/setup/releases.md +76 -0
- package/assets/tests/workflow-ref.test.ts +113 -0
- package/bin/norn.mjs +10 -0
- package/dist/build-info.d.ts +30 -0
- package/dist/build-info.js +6 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1032 -0
- package/dist/client.d.ts +48 -0
- package/dist/client.js +118 -0
- package/dist/documentation-intro.d.ts +5 -0
- package/dist/documentation-intro.js +29 -0
- package/dist/documentation.d.ts +33 -0
- package/dist/documentation.js +132 -0
- package/dist/generated-build-info.d.ts +10 -0
- package/dist/generated-build-info.js +14 -0
- package/dist/internal/agent-directory.d.ts +4 -0
- package/dist/internal/agent-directory.js +8 -0
- package/dist/internal/agent-response-tool.d.ts +21 -0
- package/dist/internal/agent-response-tool.js +79 -0
- package/dist/internal/agents.d.ts +29 -0
- package/dist/internal/agents.js +336 -0
- package/dist/internal/artifacts.d.ts +10 -0
- package/dist/internal/artifacts.js +29 -0
- package/dist/internal/commands.d.ts +18 -0
- package/dist/internal/commands.js +147 -0
- package/dist/internal/documentation-bundle.d.ts +16 -0
- package/dist/internal/documentation-bundle.js +42 -0
- package/dist/internal/engine.d.ts +44 -0
- package/dist/internal/engine.js +399 -0
- package/dist/internal/errors.d.ts +14 -0
- package/dist/internal/errors.js +38 -0
- package/dist/internal/file-names.d.ts +1 -0
- package/dist/internal/file-names.js +7 -0
- package/dist/internal/launch-request.d.ts +33 -0
- package/dist/internal/launch-request.js +110 -0
- package/dist/internal/logs.d.ts +16 -0
- package/dist/internal/logs.js +38 -0
- package/dist/internal/metrics.d.ts +19 -0
- package/dist/internal/metrics.js +282 -0
- package/dist/internal/pi-assets.d.ts +13 -0
- package/dist/internal/pi-assets.js +94 -0
- package/dist/internal/resource-bindings.d.ts +13 -0
- package/dist/internal/resource-bindings.js +34 -0
- package/dist/internal/run-lease.d.ts +32 -0
- package/dist/internal/run-lease.js +166 -0
- package/dist/internal/run-log.d.ts +30 -0
- package/dist/internal/run-log.js +71 -0
- package/dist/internal/run-names.d.ts +1 -0
- package/dist/internal/run-names.js +144 -0
- package/dist/internal/run-resources.d.ts +6 -0
- package/dist/internal/run-resources.js +26 -0
- package/dist/internal/run-state.d.ts +95 -0
- package/dist/internal/run-state.js +323 -0
- package/dist/internal/run-store.d.ts +35 -0
- package/dist/internal/run-store.js +314 -0
- package/dist/internal/run.d.ts +51 -0
- package/dist/internal/run.js +101 -0
- package/dist/internal/state-store.d.ts +22 -0
- package/dist/internal/state-store.js +97 -0
- package/dist/internal/usage.d.ts +5 -0
- package/dist/internal/usage.js +70 -0
- package/dist/internal/workflow-registry.d.ts +35 -0
- package/dist/internal/workflow-registry.js +129 -0
- package/dist/plugin-loader.d.ts +55 -0
- package/dist/plugin-loader.js +353 -0
- package/dist/resources.d.ts +11 -0
- package/dist/resources.js +98 -0
- package/package.json +52 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
8
|
+
import { writeTextAtomically } from "@vimhead.dev/norn-core/atomic-files";
|
|
9
|
+
|
|
10
|
+
const ownerSchema = z.strictObject({
|
|
11
|
+
token: z.uuid(),
|
|
12
|
+
pid: z.number().int().positive(),
|
|
13
|
+
host: z.string(),
|
|
14
|
+
target: z.string(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export class NornFileCoordinator {
|
|
18
|
+
private readonly lockRoot: string;
|
|
19
|
+
|
|
20
|
+
constructor(private readonly input: { readonly lockRoot: string; readonly waitTimeoutMs: number }) {
|
|
21
|
+
if (!Number.isSafeInteger(input.waitTimeoutMs) || input.waitTimeoutMs <= 0) throw new Error("Lock wait timeout must be a positive integer");
|
|
22
|
+
this.lockRoot = resolve(input.lockRoot);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async withExclusiveLock<T>(path: string, operation: (lockedPath: string) => Promise<T>): Promise<T> {
|
|
26
|
+
const target = await this.resolveTarget(path);
|
|
27
|
+
await mkdir(this.lockRoot, { recursive: true, mode: 0o700 });
|
|
28
|
+
const key = createHash("sha256").update(target).digest("hex");
|
|
29
|
+
const lockPath = join(this.lockRoot, `${key}.lock`);
|
|
30
|
+
const stagingPath = await mkdtemp(join(this.lockRoot, `${key}.pending-`));
|
|
31
|
+
const token = randomUUID();
|
|
32
|
+
const marker = `${token}.json`;
|
|
33
|
+
return this.withCleanup({
|
|
34
|
+
operation: async () => {
|
|
35
|
+
await writeFile(join(stagingPath, marker), JSON.stringify({ token, pid: process.pid, host: hostname(), target }), { mode: 0o600 });
|
|
36
|
+
await this.acquire({ stagingPath, lockPath, target });
|
|
37
|
+
return this.withCleanup({
|
|
38
|
+
operation: () => operation(target),
|
|
39
|
+
cleanup: async () => {
|
|
40
|
+
await unlink(join(lockPath, marker));
|
|
41
|
+
await this.removeEmptyLock(lockPath);
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
cleanup: () => rm(stagingPath, { recursive: true, force: true }),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async readText(path: string): Promise<string> {
|
|
50
|
+
return this.withExclusiveLock(path, (lockedPath) => readFile(lockedPath, "utf8"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async writeText(path: string, content: string): Promise<void> {
|
|
54
|
+
await mkdir(dirname(path), { recursive: true });
|
|
55
|
+
await this.withExclusiveLock(path, (lockedPath) => writeTextAtomically(lockedPath, content));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async withCleanup<T>(input: { readonly operation: () => Promise<T>; readonly cleanup: () => Promise<void> }): Promise<T> {
|
|
59
|
+
let result: T;
|
|
60
|
+
try {
|
|
61
|
+
result = await input.operation();
|
|
62
|
+
} catch (error) {
|
|
63
|
+
try { await input.cleanup(); }
|
|
64
|
+
catch (cleanupError) { throw new AggregateError([error, cleanupError], "File operation and lock cleanup failed"); }
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
await input.cleanup();
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async resolveTarget(path: string): Promise<string> {
|
|
72
|
+
try {
|
|
73
|
+
return await realpath(path);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
76
|
+
const entry = await lstat(path).catch((error: unknown) => {
|
|
77
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
80
|
+
if (entry?.isSymbolicLink()) throw new Error(`Cannot lock a dangling symbolic link: ${path}`);
|
|
81
|
+
return join(await realpath(dirname(path)), basename(path));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async acquire(input: { readonly stagingPath: string; readonly lockPath: string; readonly target: string }): Promise<void> {
|
|
86
|
+
const deadline = Date.now() + this.input.waitTimeoutMs;
|
|
87
|
+
while (true) {
|
|
88
|
+
try {
|
|
89
|
+
await rename(input.stagingPath, input.lockPath);
|
|
90
|
+
return;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (!isNodeError(error) || !["ENOTEMPTY", "EEXIST"].includes(error.code ?? "")) throw error;
|
|
93
|
+
}
|
|
94
|
+
await this.reclaimDeadOwner(input);
|
|
95
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for file lock: ${input.target}`);
|
|
96
|
+
await delay(10);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async reclaimDeadOwner(input: { readonly lockPath: string; readonly target: string }): Promise<void> {
|
|
101
|
+
try {
|
|
102
|
+
const entries = await readdir(input.lockPath);
|
|
103
|
+
if (entries.length === 0) return;
|
|
104
|
+
if (entries.length !== 1) throw new Error(`Invalid file lock ownership: ${input.lockPath}`);
|
|
105
|
+
const marker = entries[0];
|
|
106
|
+
const owner = ownerSchema.parse(JSON.parse(await readFile(join(input.lockPath, marker), "utf8")));
|
|
107
|
+
if (marker !== `${owner.token}.json` || owner.target !== input.target || owner.host !== hostname()) {
|
|
108
|
+
throw new Error(`Incompatible file lock ownership: ${input.lockPath}`);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
process.kill(owner.pid, 0);
|
|
112
|
+
return;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (isNodeError(error) && error.code === "EPERM") return;
|
|
115
|
+
if (!isNodeError(error) || error.code !== "ESRCH") throw error;
|
|
116
|
+
}
|
|
117
|
+
// A delayed reaper must never remove a successor's differently named marker.
|
|
118
|
+
await unlink(join(input.lockPath, marker));
|
|
119
|
+
await this.removeEmptyLock(input.lockPath);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async removeEmptyLock(path: string): Promise<void> {
|
|
126
|
+
try {
|
|
127
|
+
await rmdir(path);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (!isNodeError(error) || !["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code ?? "")) throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createRunFileCoordinator(runRoot: string): NornFileCoordinator {
|
|
135
|
+
return new NornFileCoordinator({ lockRoot: join(runRoot, "locks"), waitTimeoutMs: 30_000 });
|
|
136
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { NornFileCoordinator } from "./files.ts";
|
|
3
|
+
|
|
4
|
+
export type NornResourceContext = {
|
|
5
|
+
readonly mode: "create" | "open";
|
|
6
|
+
readonly directory: string;
|
|
7
|
+
readonly files: NornFileCoordinator;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type NornResourceDefinition<T> = {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly kind: string;
|
|
13
|
+
readonly configuration: z.infer<ReturnType<typeof z.json>>;
|
|
14
|
+
initialize(context: NornResourceContext): Promise<T>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type NornResources = {
|
|
18
|
+
readonly files: NornFileCoordinator;
|
|
19
|
+
ensure<T>(definition: NornResourceDefinition<T>): Promise<T>;
|
|
20
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { NornAnyWorkflowDeclaration } from "./api.ts";
|
|
2
|
+
|
|
3
|
+
export function assertWorkflowMetadata(workflow: NornAnyWorkflowDeclaration): void {
|
|
4
|
+
if (workflow.instructions !== undefined && (typeof workflow.instructions !== "string" || workflow.instructions.trim().length === 0)) {
|
|
5
|
+
throw new Error(`Workflow instructions must be a nonempty string: ${workflow.id}`);
|
|
6
|
+
}
|
|
7
|
+
if (workflow.isEntrypoint && workflow.instructions === undefined) {
|
|
8
|
+
throw new Error(`Entrypoint workflow requires instructions: ${workflow.id}`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function unwrapSchema(schema: unknown): unknown {
|
|
13
|
+
let current = schema;
|
|
14
|
+
while (true) {
|
|
15
|
+
const def = schemaDef(current);
|
|
16
|
+
if (["optional", "nullable", "default", "catch", "readonly", "prefault"].includes(def.type ?? "") && def.innerType) {
|
|
17
|
+
current = def.innerType;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
return current;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function schemaShape(schema: unknown): Record<string, unknown> {
|
|
25
|
+
const shape = schemaDef(unwrapSchema(schema)).shape;
|
|
26
|
+
if (!shape) return {};
|
|
27
|
+
return typeof shape === "function" ? shape() as Record<string, unknown> : shape as Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function schemaType(schema: unknown): string | undefined {
|
|
31
|
+
return schemaDef(unwrapSchema(schema)).type;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
35
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type ZodDef = {
|
|
39
|
+
readonly type?: string;
|
|
40
|
+
readonly innerType?: unknown;
|
|
41
|
+
readonly shape?: unknown;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function schemaDef(schema: unknown): ZodDef {
|
|
45
|
+
if (!schema || typeof schema !== "object") return {};
|
|
46
|
+
const candidate = schema as { _def?: ZodDef; def?: ZodDef };
|
|
47
|
+
return candidate._def ?? candidate.def ?? {};
|
|
48
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
export type NornSeerModeConfig = {
|
|
4
|
+
readonly writableRoots: readonly string[];
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type NornResolvedSeerModeConfig = {
|
|
8
|
+
readonly configPath: string;
|
|
9
|
+
readonly projectRoot: string;
|
|
10
|
+
readonly writableRoots: readonly string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ResolveSeerModeConfigInput = {
|
|
14
|
+
readonly configPath: string;
|
|
15
|
+
readonly configRoot: string;
|
|
16
|
+
readonly seerMode: NornSeerModeConfig | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function resolveSeerModeConfig(input: ResolveSeerModeConfigInput): NornResolvedSeerModeConfig | undefined {
|
|
20
|
+
if (!input.seerMode) return undefined;
|
|
21
|
+
if (input.seerMode.writableRoots.length === 0) throw new Error("Norn seerMode.writableRoots must not be empty");
|
|
22
|
+
const projectRoot = resolve(input.configRoot);
|
|
23
|
+
const writableRoots = Array.from(new Set(input.seerMode.writableRoots.map((path) => resolveWritableRoot(projectRoot, path))));
|
|
24
|
+
return { configPath: input.configPath, projectRoot, writableRoots };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assertSeerModeWritablePath(seerMode: NornResolvedSeerModeConfig, cwd: string, path: string): string {
|
|
28
|
+
const resolvedPath = resolveSeerModePath(seerMode, cwd, path);
|
|
29
|
+
if (!isSeerModeWritableResolvedPath(seerMode, resolvedPath)) {
|
|
30
|
+
throw new Error(`Path is outside Norn seerMode writable roots: ${path}`);
|
|
31
|
+
}
|
|
32
|
+
return resolvedPath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isSeerModeWritablePath(seerMode: NornResolvedSeerModeConfig, cwd: string, path: string): boolean {
|
|
36
|
+
return isSeerModeWritableResolvedPath(seerMode, resolveSeerModePath(seerMode, cwd, path));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveWritableRoot(projectRoot: string, path: string): string {
|
|
40
|
+
if (path.length === 0) throw new Error("Norn seerMode writable root must not be empty");
|
|
41
|
+
const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(projectRoot, path);
|
|
42
|
+
if (!isInsideOrEqual(projectRoot, resolvedPath)) throw new Error(`Norn seerMode writable root escapes project root: ${path}`);
|
|
43
|
+
return resolvedPath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveSeerModePath(seerMode: NornResolvedSeerModeConfig, cwd: string, path: string): string {
|
|
47
|
+
if (path.length === 0) throw new Error("Norn seerMode path must not be empty");
|
|
48
|
+
const resolvedCwd = resolve(cwd);
|
|
49
|
+
if (!isInsideOrEqual(seerMode.projectRoot, resolvedCwd)) throw new Error(`Norn seerMode cwd escapes project root: ${cwd}`);
|
|
50
|
+
const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedCwd, path);
|
|
51
|
+
if (!isInsideOrEqual(seerMode.projectRoot, resolvedPath)) return resolvedPath;
|
|
52
|
+
return resolvedPath;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isSeerModeWritableResolvedPath(seerMode: NornResolvedSeerModeConfig, path: string): boolean {
|
|
56
|
+
return seerMode.writableRoots.some((root) => isInsideOrEqual(root, path));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isInsideOrEqual(root: string, path: string): boolean {
|
|
60
|
+
const pathFromRoot = relative(root, path);
|
|
61
|
+
return pathFromRoot === "" || (!pathFromRoot.startsWith(`..${sep}`) && pathFromRoot !== ".." && !isAbsolute(pathFromRoot));
|
|
62
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { NornWorkflowState, NornWorkflowStateDefinition } from "./api.ts";
|
|
6
|
+
import type { NornAgentResourceAdapter } from "./agent-resource-adapter.ts";
|
|
7
|
+
|
|
8
|
+
export type NornStateFieldAccess = {
|
|
9
|
+
readonly field: NornWorkflowStateDefinition;
|
|
10
|
+
readonly access: "read" | "write" | "read-write";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const pageParameters = {
|
|
14
|
+
offset: Type.Integer({ minimum: 0, description: "Zero-based UTF-16 offset into the serialized JSON. Start at 0." }),
|
|
15
|
+
limit: Type.Integer({ minimum: 1, maximum: 10000 }),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function StateAdapter(input: { readonly state: NornWorkflowState; readonly fields: readonly NornStateFieldAccess[] }): NornAgentResourceAdapter {
|
|
19
|
+
const fields = new Map(input.fields.map((grant) => [grant.field.id, grant]));
|
|
20
|
+
if (fields.size !== input.fields.length || fields.size === 0) throw new Error("State attachment requires unique, explicitly selected fields");
|
|
21
|
+
const selectField = (key: string, access: "read" | "write") => {
|
|
22
|
+
const grant = fields.get(key);
|
|
23
|
+
if (!grant || (grant.access !== access && grant.access !== "read-write")) throw new Error(`State ${access} is not attached: ${key}`);
|
|
24
|
+
return grant.field;
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
name: "norn.state",
|
|
28
|
+
async bind() {
|
|
29
|
+
return {
|
|
30
|
+
tools: [
|
|
31
|
+
defineTool({
|
|
32
|
+
name: "norn_state_list",
|
|
33
|
+
label: "Attached workflow state",
|
|
34
|
+
description: "List only attached workflow-state field IDs, permissions and value schemas. JSON is paginated; use nextOffset until null.",
|
|
35
|
+
parameters: Type.Object(pageParameters),
|
|
36
|
+
async execute(_id, params) {
|
|
37
|
+
return serializePage({ value: [...fields.values()].map(({ field, access }) => ({ id: field.id, access, schema: z.toJSONSchema(field.schema, { io: "input" }) })), ...params });
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
defineTool({
|
|
41
|
+
name: "norn_state_get",
|
|
42
|
+
label: "Read workflow state",
|
|
43
|
+
description: "Read a selected workflow-state field. Unset fields return isSet:false. JSON is paginated; concurrent writes can change later pages, so compare revision before combining pages.",
|
|
44
|
+
parameters: Type.Object({ key: Type.String(), ...pageParameters }),
|
|
45
|
+
async execute(_id, params) {
|
|
46
|
+
const value = await input.state.getOptional(selectField(params.key, "read"));
|
|
47
|
+
return serializePage({ value: value === undefined ? { isSet: false } : { isSet: true, value }, ...params });
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
defineTool({
|
|
51
|
+
name: "norn_state_set",
|
|
52
|
+
label: "Write workflow state",
|
|
53
|
+
description: "Set an explicitly writable workflow-state field. Validate the value against its schema from norn_state_list. A get followed by set is not a transaction.",
|
|
54
|
+
parameters: Type.Object({ key: Type.String(), value: Type.Unknown() }),
|
|
55
|
+
async execute(_id, params, signal) {
|
|
56
|
+
signal?.throwIfAborted();
|
|
57
|
+
const field = selectField(params.key, "write");
|
|
58
|
+
await input.state.set(field, params.value);
|
|
59
|
+
return { content: [{ type: "text", text: "Workflow state saved." }], details: {} };
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
],
|
|
63
|
+
async dispose() {},
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function serializePage(input: { readonly value: unknown; readonly offset: number; readonly limit: number }) {
|
|
70
|
+
const serialized = JSON.stringify(input.value);
|
|
71
|
+
if (!Number.isInteger(input.offset) || input.offset < 0 || !Number.isInteger(input.limit) || input.limit < 1 || input.limit > 10000) throw new Error("Invalid state output page");
|
|
72
|
+
const end = Math.min(serialized.length, input.offset + input.limit);
|
|
73
|
+
const details = { text: serialized.slice(input.offset, end), nextOffset: end < serialized.length ? end : null, revision: createHash("sha256").update(serialized).digest("hex") };
|
|
74
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(details) }], details };
|
|
75
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Providers and authentication
|
|
2
|
+
|
|
3
|
+
`norn pi` runs the Pi CLI bundled with the selected Norn installation. It needs
|
|
4
|
+
neither a separate Pi installation nor a Norn project. Everything after `pi` is
|
|
5
|
+
forwarded to Pi, including its terminal UI, package commands, and output modes:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
norn pi --version
|
|
9
|
+
norn pi --help
|
|
10
|
+
norn pi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
In the interactive session, use `/login` to authenticate. Open `/model`, highlight
|
|
14
|
+
the desired model, and press **Ctrl+S** to save it as the startup default. Selecting
|
|
15
|
+
a model is not the same as saving a startup default. Use `/quit` to exit.
|
|
16
|
+
Configure these before launching Norn agents: detached execution cannot conduct
|
|
17
|
+
interactive login. Authentication in the outer harness does not automatically
|
|
18
|
+
authenticate a Norn agent's provider.
|
|
19
|
+
|
|
20
|
+
## Norn configuration
|
|
21
|
+
|
|
22
|
+
The proxy and Norn agents share Norn's agent directory, normally `~/.norn/agent/`.
|
|
23
|
+
It is independent of the outer Pi harness's global configuration:
|
|
24
|
+
|
|
25
|
+
- `settings.json` — installed packages and default provider/model
|
|
26
|
+
- `auth.json` — saved API keys and OAuth credentials
|
|
27
|
+
- `models.json` — custom endpoints, models, and authentication configuration
|
|
28
|
+
|
|
29
|
+
`NORN_AGENT_DIR` selects another directory. Set it consistently for both
|
|
30
|
+
`norn pi` and workflow execution. A programmatic runtime caller supplying `agentDir`
|
|
31
|
+
must point the setup command at that same directory. Norn does not use an inherited
|
|
32
|
+
`PI_CODING_AGENT_DIR` to select its global configuration. Existing Pi packages and
|
|
33
|
+
credentials are not imported or linked: install providers and authenticate through
|
|
34
|
+
`norn pi`, even when Pi is the outer harness.
|
|
35
|
+
|
|
36
|
+
Project-local `.pi` settings and extension discovery still depend on the working
|
|
37
|
+
directory; installing a provider globally avoids making
|
|
38
|
+
it available only in one project or worktree.
|
|
39
|
+
|
|
40
|
+
Login is optional when credentials are supplied another way. Norn agents inherit
|
|
41
|
+
provider environment variables such as `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`.
|
|
42
|
+
Custom providers can resolve environment variables or secret-manager commands
|
|
43
|
+
through `models.json`. Keep credentials out of project files and version control.
|
|
44
|
+
Finding a configured key or listing a model is not proof that a provider accepts it.
|
|
45
|
+
|
|
46
|
+
## Third-party provider packages
|
|
47
|
+
|
|
48
|
+
Pi provider extensions are separate from Norn workflow plugins and outer-harness
|
|
49
|
+
adapters. They register providers through Pi's APIs; do not put them in
|
|
50
|
+
`norn.project.json` or install them only in Cursor's plugin marketplace.
|
|
51
|
+
|
|
52
|
+
Use `norn pi install` with the package's published npm spec or Git URL. For example:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
norn pi install npm:pi-cursor-sdk@0.3.6
|
|
56
|
+
norn pi --list-models cursor
|
|
57
|
+
norn pi
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This installs into Norn's global configuration. Select the new provider using
|
|
61
|
+
`/model`. Agent resource discovery and model precedence are covered in
|
|
62
|
+
[Norn agents](../docs/agents.md#prompts-tools-and-resource-loading).
|
|
63
|
+
Use `norn pi list` to inspect installations and `norn pi remove <source>` to remove
|
|
64
|
+
one. Upgrade bundled Pi by upgrading Norn, not by using Pi's self-update command.
|
|
65
|
+
`norn pi update --extensions` updates unpinned extension packages.
|
|
66
|
+
|
|
67
|
+
Review third-party packages before installation: extensions and dependency install
|
|
68
|
+
scripts execute code with the user's permissions. npm packages require Node/npm;
|
|
69
|
+
Git sources additionally require Git. A standalone Norn binary does not bundle
|
|
70
|
+
those installers or every third-party package's native dependencies. Check package
|
|
71
|
+
requirements against `norn pi --version` and the local platform.
|
|
72
|
+
|
|
73
|
+
### Cursor SDK example
|
|
74
|
+
|
|
75
|
+
[pi-cursor-sdk](https://pi.dev/packages/pi-cursor-sdk) registers the `cursor`
|
|
76
|
+
provider. Its API key must be a **Cursor SDK API key** (user or service account),
|
|
77
|
+
not a Team Admin key. It does not reuse Cursor Desktop or Agent CLI login.
|
|
78
|
+
|
|
79
|
+
After installation, run `/login cursor` inside `norn pi` and enter the SDK key,
|
|
80
|
+
or supply `CURSOR_API_KEY` to Norn's launching environment. Select a Cursor model
|
|
81
|
+
with `/model`. A newly started session reloads the provider; its model listing can
|
|
82
|
+
contain fallback models even without working credentials.
|
|
83
|
+
|
|
84
|
+
Keep the provider's **local runtime and Pi tool bridge enabled** for Norn agents.
|
|
85
|
+
Norn requires its structured-response tool, and attached resources also expose Pi
|
|
86
|
+
tools. The provider's cloud mode does not expose that local bridge. Cursor-native
|
|
87
|
+
tools are a separate surface: restricting Norn's `tools` list does not disable
|
|
88
|
+
Cursor's own tools or ambient configuration. See the package's documentation for
|
|
89
|
+
its runtime and isolation controls.
|
|
90
|
+
|
|
91
|
+
Installation and fallback-model discovery for version 0.3.6 were exercised through
|
|
92
|
+
a standalone Norn binary without credentials. Authenticated Cursor inference and
|
|
93
|
+
its Norn response-tool bridge require a separate live check; model discovery alone
|
|
94
|
+
does not establish end-to-end compatibility.
|
|
95
|
+
|
|
96
|
+
## Custom endpoints without an extension
|
|
97
|
+
|
|
98
|
+
For a provider using an existing API protocol, add its configuration under
|
|
99
|
+
`providers` in the shared `models.json`, preserving existing entries:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"providers": {
|
|
104
|
+
"team-gateway": {
|
|
105
|
+
"baseUrl": "https://gateway.example.com/v1",
|
|
106
|
+
"api": "openai-completions",
|
|
107
|
+
"apiKey": "$TEAM_GATEWAY_API_KEY",
|
|
108
|
+
"models": [{ "id": "team-model" }]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Set `TEAM_GATEWAY_API_KEY` in the launching environment, then select the model
|
|
115
|
+
through `norn pi`. A provider with a different protocol or custom OAuth flow needs
|
|
116
|
+
a compatible Pi provider extension, not just an `auth.json` entry. Use that
|
|
117
|
+
extension's documented authentication method; Norn does not duplicate its login
|
|
118
|
+
or token-refresh implementation.
|
|
119
|
+
|
|
120
|
+
## Standalone assets
|
|
121
|
+
|
|
122
|
+
Compiled Norn includes the bundled Pi version metadata, themes, documentation,
|
|
123
|
+
examples, and export assets. It extracts and verifies them in a content-specific
|
|
124
|
+
cache before loading Pi. `NORN_PI_CACHE_DIR` overrides its root; otherwise it uses
|
|
125
|
+
`~/Library/Caches/norn/pi` on macOS, `$XDG_CACHE_HOME/norn/pi` (or
|
|
126
|
+
`~/.cache/norn/pi`) on Linux, and `%LOCALAPPDATA%/norn/pi` on Windows.
|
|
127
|
+
The asset cache is not the credential directory. A modified or incomplete entry
|
|
128
|
+
causes an error naming the entry to remove before retrying.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Rolling releases
|
|
2
|
+
|
|
3
|
+
The public npm packages are `@vimhead.dev/norn`, `@vimhead.dev/norn-cli`, and
|
|
4
|
+
`@vimhead.dev/pi-norn`. The root and core workspaces are private.
|
|
5
|
+
|
|
6
|
+
## Local preparation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm install --frozen-lockfile
|
|
10
|
+
pnpm check
|
|
11
|
+
pnpm test
|
|
12
|
+
pnpm pack:dry
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`pack:dry` writes real pnpm tarballs and a checksum manifest into `dist/npm/`;
|
|
16
|
+
it does not publish. pnpm rewrites the CLI's workspace dependency to the exact
|
|
17
|
+
Norn library version. Consumer builds inline private core code.
|
|
18
|
+
|
|
19
|
+
## One-time npm bootstrap
|
|
20
|
+
|
|
21
|
+
An npm owner with access to the `vimhead.dev` organization must publish each new
|
|
22
|
+
package before configuring its trusted publisher. Use a disposable checkout so
|
|
23
|
+
release version stamping does not modify the development checkout:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm login --registry=https://registry.npmjs.org/
|
|
27
|
+
git worktree add --detach /tmp/norn-bootstrap HEAD
|
|
28
|
+
cd /tmp/norn-bootstrap
|
|
29
|
+
pnpm install --frozen-lockfile
|
|
30
|
+
pnpm release:prepare 0.1.0-tip.0 "$(git rev-parse HEAD)"
|
|
31
|
+
pnpm release:pack
|
|
32
|
+
pnpm test
|
|
33
|
+
pnpm release:publish --interactive
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Choose an unused prerelease version if `0.1.0-tip.0` already exists. The publication
|
|
37
|
+
command prompts through npm for any required authentication/2FA; never put
|
|
38
|
+
credentials in repository files. It publishes the library before the CLI and
|
|
39
|
+
adapter, with public access and the `tip` tag, never `latest`.
|
|
40
|
+
|
|
41
|
+
For **each** package, configure an npm trusted publisher:
|
|
42
|
+
|
|
43
|
+
- Provider: GitHub Actions
|
|
44
|
+
- Organization/user: `vimhead`
|
|
45
|
+
- Repository: `norn`
|
|
46
|
+
- Workflow filename: `release.yml`
|
|
47
|
+
- Environment: unset (the workflow does not name one)
|
|
48
|
+
- Allowed action: enable direct `npm publish`, not only staged publishing
|
|
49
|
+
|
|
50
|
+
Use npm's [trusted-publisher settings](https://docs.npmjs.com/trusted-publishers/).
|
|
51
|
+
GitHub-hosted runners request OIDC credentials through `id-token: write`; no npm
|
|
52
|
+
publishing token is stored in GitHub secrets. Public releases include provenance.
|
|
53
|
+
|
|
54
|
+
## Continuous tip publication and recovery
|
|
55
|
+
|
|
56
|
+
After a successful `Test` run on `main`, `Release Tip` stamps all three packages
|
|
57
|
+
with `0.1.0-tip.<GitHub-run-id>.<attempt>` and the tested commit. It validates and
|
|
58
|
+
packs npm artifacts, builds standalone binaries, then publishes only if the
|
|
59
|
+
selected commit is still `main`. Manual dispatch performs the same checks.
|
|
60
|
+
Only after all npm packages are verified does it update the GitHub `tip` release.
|
|
61
|
+
|
|
62
|
+
npm versions are immutable. A workflow rerun gets a new attempt/version, so a
|
|
63
|
+
partial prior publication is retained rather than overwritten. Repeating the
|
|
64
|
+
publication command for the same artifacts skips an existing package only when
|
|
65
|
+
its integrity and `tip` version match; conflicting bytes or a moved tag fail
|
|
66
|
+
closed and require a new version. Completed npm publication followed by a GitHub
|
|
67
|
+
failure can likewise be repaired by rerunning the workflow.
|
|
68
|
+
|
|
69
|
+
npm tags move package by package; npm and GitHub do not provide a cross-registry
|
|
70
|
+
transaction. The CLI always uses its exact library dependency, even during a
|
|
71
|
+
partial rollout. Install that version of `@vimhead.dev/norn` for matching editor
|
|
72
|
+
types rather than assuming independently read `tip` tags are atomic.
|
|
73
|
+
|
|
74
|
+
Trusted publishing authenticates `npm publish`, not general tag-management
|
|
75
|
+
commands. The workflow sets `tip` through publication itself; it does not rely on
|
|
76
|
+
`npm dist-tag` or a long-lived token for a separate promotion step.
|