@manyoars/oars 0.2.0 → 0.3.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 +132 -28
- package/dist/api.js +1 -1
- package/dist/bin.js +6 -29
- package/dist/cli.d.ts +6 -3
- package/dist/cli.js +367 -88
- package/dist/commands/keys.d.ts +31 -0
- package/dist/commands/keys.js +65 -0
- package/dist/commands/login.js +2 -2
- package/dist/errors.d.ts +13 -13
- package/dist/errors.js +28 -11
- package/dist/gate/checks.d.ts +68 -0
- package/dist/gate/checks.js +584 -0
- package/dist/gate/command.d.ts +14 -0
- package/dist/gate/command.js +61 -0
- package/dist/gate/hook-fill-main.d.ts +1 -0
- package/dist/gate/hook-fill-main.js +16 -0
- package/dist/gate/hook-fill.d.ts +38 -0
- package/dist/gate/hook-fill.js +94 -0
- package/dist/gate/hook-main.d.ts +1 -0
- package/dist/gate/hook-main.js +35 -0
- package/dist/gate/hook-process.d.ts +14 -0
- package/dist/gate/hook-process.js +64 -0
- package/dist/gate/hook.d.ts +46 -0
- package/dist/gate/hook.js +256 -0
- package/dist/gate/install.d.ts +25 -0
- package/dist/gate/install.js +126 -0
- package/dist/gate/local-bin.d.ts +8 -0
- package/dist/gate/local-bin.js +36 -0
- package/dist/gate/notes.d.ts +19 -0
- package/dist/gate/notes.js +27 -0
- package/dist/gate/novelty.d.ts +35 -0
- package/dist/gate/novelty.js +99 -0
- package/dist/gate/policy.d.ts +25 -0
- package/dist/gate/policy.js +73 -0
- package/dist/gate/record.d.ts +120 -0
- package/dist/gate/record.js +125 -0
- package/dist/gate/registry-state.d.ts +10 -0
- package/dist/gate/registry-state.js +30 -0
- package/dist/gate/registry.d.ts +56 -0
- package/dist/gate/registry.js +94 -0
- package/dist/gate/replay.d.ts +20 -0
- package/dist/gate/replay.js +257 -0
- package/dist/gate/report.d.ts +50 -0
- package/dist/gate/report.js +89 -0
- package/dist/gate/scope.d.ts +7 -0
- package/dist/gate/scope.js +27 -0
- package/dist/gate/transcripts.d.ts +68 -0
- package/dist/gate/transcripts.js +232 -0
- package/dist/hook-bin.d.ts +1 -0
- package/dist/hook-bin.js +25 -0
- package/dist/issuer.d.ts +7 -1
- package/dist/issuer.js +18 -1
- package/dist/main.d.ts +1 -0
- package/dist/main.js +64 -0
- package/dist/oidc.js +1 -1
- package/dist/sessions/actions.d.ts +35 -0
- package/dist/sessions/actions.js +351 -0
- package/dist/sessions/allow.d.ts +14 -0
- package/dist/sessions/allow.js +51 -0
- package/dist/sessions/archive.d.ts +11 -0
- package/dist/sessions/archive.js +38 -0
- package/dist/sessions/audit.d.ts +42 -0
- package/dist/sessions/audit.js +104 -0
- package/dist/sessions/baseline.d.ts +62 -0
- package/dist/sessions/baseline.js +188 -0
- package/dist/sessions/capture-runtime.d.ts +10 -0
- package/dist/sessions/capture-runtime.js +12 -0
- package/dist/sessions/cell-increments.d.ts +43 -0
- package/dist/sessions/cell-increments.js +209 -0
- package/dist/sessions/cells.d.ts +79 -0
- package/dist/sessions/cells.js +181 -0
- package/dist/sessions/codex.d.ts +53 -0
- package/dist/sessions/codex.js +679 -0
- package/dist/sessions/command.d.ts +117 -0
- package/dist/sessions/command.js +373 -0
- package/dist/sessions/copilot.d.ts +40 -0
- package/dist/sessions/copilot.js +432 -0
- package/dist/sessions/cursor.d.ts +24 -0
- package/dist/sessions/cursor.js +324 -0
- package/dist/sessions/derive.d.ts +57 -0
- package/dist/sessions/derive.js +320 -0
- package/dist/sessions/exclusion.d.ts +81 -0
- package/dist/sessions/exclusion.js +353 -0
- package/dist/sessions/extract.d.ts +179 -0
- package/dist/sessions/extract.js +1774 -0
- package/dist/sessions/fold.d.ts +17 -0
- package/dist/sessions/fold.js +71 -0
- package/dist/sessions/frame.d.ts +24 -0
- package/dist/sessions/frame.js +95 -0
- package/dist/sessions/gemini.d.ts +37 -0
- package/dist/sessions/gemini.js +613 -0
- package/dist/sessions/gold-arms.d.ts +67 -0
- package/dist/sessions/gold-arms.js +172 -0
- package/dist/sessions/gold.d.ts +197 -0
- package/dist/sessions/gold.js +463 -0
- package/dist/sessions/graph.d.ts +12 -0
- package/dist/sessions/graph.js +34 -0
- package/dist/sessions/grok.d.ts +36 -0
- package/dist/sessions/grok.js +421 -0
- package/dist/sessions/hook-calls.d.ts +3 -0
- package/dist/sessions/hook-calls.js +47 -0
- package/dist/sessions/hook-index.d.ts +53 -0
- package/dist/sessions/hook-index.js +157 -0
- package/dist/sessions/hooks.d.ts +13 -0
- package/dist/sessions/hooks.js +170 -0
- package/dist/sessions/identity.d.ts +22 -0
- package/dist/sessions/identity.js +101 -0
- package/dist/sessions/install.d.ts +54 -0
- package/dist/sessions/install.js +201 -0
- package/dist/sessions/lineage.d.ts +19 -0
- package/dist/sessions/lineage.js +118 -0
- package/dist/sessions/mcp.d.ts +6 -0
- package/dist/sessions/mcp.js +153 -0
- package/dist/sessions/merkle.d.ts +7 -0
- package/dist/sessions/merkle.js +28 -0
- package/dist/sessions/motif.d.ts +43 -0
- package/dist/sessions/motif.js +59 -0
- package/dist/sessions/package-resolve.d.ts +30 -0
- package/dist/sessions/package-resolve.js +183 -0
- package/dist/sessions/pending.d.ts +34 -0
- package/dist/sessions/pending.js +142 -0
- package/dist/sessions/pool-command.d.ts +44 -0
- package/dist/sessions/pool-command.js +542 -0
- package/dist/sessions/pool-delta.d.ts +54 -0
- package/dist/sessions/pool-delta.js +190 -0
- package/dist/sessions/pool-outbox.d.ts +63 -0
- package/dist/sessions/pool-outbox.js +260 -0
- package/dist/sessions/pool-print.d.ts +26 -0
- package/dist/sessions/pool-print.js +68 -0
- package/dist/sessions/pool-wire.d.ts +225 -0
- package/dist/sessions/pool-wire.js +620 -0
- package/dist/sessions/pool.d.ts +32 -0
- package/dist/sessions/pool.js +164 -0
- package/dist/sessions/projection.d.ts +75 -0
- package/dist/sessions/projection.js +165 -0
- package/dist/sessions/public-vocabulary.d.ts +73 -0
- package/dist/sessions/public-vocabulary.js +302 -0
- package/dist/sessions/query.d.ts +111 -0
- package/dist/sessions/query.js +246 -0
- package/dist/sessions/read-adoption.d.ts +2 -0
- package/dist/sessions/read-adoption.js +27 -0
- package/dist/sessions/read-call-shape.d.ts +2 -0
- package/dist/sessions/read-call-shape.js +25 -0
- package/dist/sessions/read-change.d.ts +2 -0
- package/dist/sessions/read-change.js +30 -0
- package/dist/sessions/read-cost.d.ts +4 -0
- package/dist/sessions/read-cost.js +37 -0
- package/dist/sessions/read-day-move.d.ts +3 -0
- package/dist/sessions/read-day-move.js +65 -0
- package/dist/sessions/read-fit.d.ts +7 -0
- package/dist/sessions/read-fit.js +41 -0
- package/dist/sessions/read-interaction.d.ts +5 -0
- package/dist/sessions/read-interaction.js +62 -0
- package/dist/sessions/read-lead-lag.d.ts +15 -0
- package/dist/sessions/read-lead-lag.js +81 -0
- package/dist/sessions/read-live.d.ts +6 -0
- package/dist/sessions/read-live.js +38 -0
- package/dist/sessions/read-method.d.ts +2 -0
- package/dist/sessions/read-method.js +29 -0
- package/dist/sessions/read-next-step.d.ts +2 -0
- package/dist/sessions/read-next-step.js +35 -0
- package/dist/sessions/read-order.d.ts +54 -0
- package/dist/sessions/read-order.js +62 -0
- package/dist/sessions/read-readable.d.ts +8 -0
- package/dist/sessions/read-readable.js +24 -0
- package/dist/sessions/read-recovery.d.ts +20 -0
- package/dist/sessions/read-recovery.js +97 -0
- package/dist/sessions/read-rows.d.ts +13 -0
- package/dist/sessions/read-rows.js +47 -0
- package/dist/sessions/read-substitution.d.ts +5 -0
- package/dist/sessions/read-substitution.js +51 -0
- package/dist/sessions/read-support.d.ts +48 -0
- package/dist/sessions/read-support.js +73 -0
- package/dist/sessions/read-tool-day.d.ts +2 -0
- package/dist/sessions/read-tool-day.js +26 -0
- package/dist/sessions/read-version-boundary.d.ts +31 -0
- package/dist/sessions/read-version-boundary.js +95 -0
- package/dist/sessions/read.d.ts +79 -0
- package/dist/sessions/read.js +84 -0
- package/dist/sessions/recall-consequence.d.ts +45 -0
- package/dist/sessions/recall-consequence.js +179 -0
- package/dist/sessions/recall-object.d.ts +137 -0
- package/dist/sessions/recall-object.js +77 -0
- package/dist/sessions/recall-pool.d.ts +19 -0
- package/dist/sessions/recall-pool.js +96 -0
- package/dist/sessions/recall-push.d.ts +26 -0
- package/dist/sessions/recall-push.js +95 -0
- package/dist/sessions/recall-reads.d.ts +117 -0
- package/dist/sessions/recall-reads.js +410 -0
- package/dist/sessions/recall-render.d.ts +19 -0
- package/dist/sessions/recall-render.js +168 -0
- package/dist/sessions/redact.d.ts +6 -0
- package/dist/sessions/redact.js +54 -0
- package/dist/sessions/repo-root.d.ts +10 -0
- package/dist/sessions/repo-root.js +101 -0
- package/dist/sessions/schema.d.ts +644 -0
- package/dist/sessions/schema.js +148 -0
- package/dist/sessions/session-shape.d.ts +73 -0
- package/dist/sessions/session-shape.js +342 -0
- package/dist/sessions/sources.d.ts +50 -0
- package/dist/sessions/sources.js +159 -0
- package/dist/sessions/steps.d.ts +105 -0
- package/dist/sessions/steps.js +250 -0
- package/dist/sessions/store.d.ts +297 -0
- package/dist/sessions/store.js +1478 -0
- package/dist/sessions/strata.d.ts +26 -0
- package/dist/sessions/strata.js +77 -0
- package/dist/sessions/survival.d.ts +25 -0
- package/dist/sessions/survival.js +230 -0
- package/dist/sessions/verify.d.ts +53 -0
- package/dist/sessions/verify.js +286 -0
- package/dist/sessions/vocab-load.d.ts +26 -0
- package/dist/sessions/vocab-load.js +61 -0
- package/dist/sessions/vscode-copilot.d.ts +38 -0
- package/dist/sessions/vscode-copilot.js +477 -0
- package/package.json +4 -5
- package/vocab/crates.json +1 -0
- package/vocab/go.json +1 -0
- package/vocab/harness.json +1 -0
- package/vocab/homebrew.json +1 -0
- package/vocab/manifest.json +1 -0
- package/vocab/maven.json +1 -0
- package/vocab/models.json +1 -0
- package/vocab/npm.json +1 -0
- package/vocab/programs.json +1 -0
- package/vocab/pypi.json +1 -0
- package/vocab/rubygems.json +1 -0
- package/dist/commands/deploy-input.d.ts +0 -38
- package/dist/commands/deploy-input.js +0 -150
- package/dist/commands/deploy.d.ts +0 -22
- package/dist/commands/deploy.js +0 -124
- package/dist/commands/status.d.ts +0 -7
- package/dist/commands/status.js +0 -18
- package/dist/commands/templates.d.ts +0 -11
- package/dist/commands/templates.js +0 -32
- package/dist/commands/test-charge.d.ts +0 -11
- package/dist/commands/test-charge.js +0 -34
- package/dist/journal.d.ts +0 -26
- package/dist/journal.js +0 -58
- package/parity/deploy-plans/paid-api.json +0 -34
- package/parity/deploy-plans/per-task-agent.json +0 -34
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The tools whose calls can cross: shell commands (both shells), skills, and every MCP tool. */
|
|
2
|
+
export declare const MATCHER = "Bash|PowerShell|Skill|mcp__.*";
|
|
3
|
+
export declare const EVENT = "PreToolUse";
|
|
4
|
+
/** Seconds the harness waits for the hook; the hook ends itself in under one, so this is only the ceiling for a cold node start. */
|
|
5
|
+
export declare const TIMEOUT_SECONDS = 5;
|
|
6
|
+
/**
|
|
7
|
+
* Run by the harness's shell (Git Bash on Windows). `command -v` is a shell builtin, so a missing `oars` costs a few milliseconds and no node start. The harness reads this command's stdout, so what
|
|
8
|
+
* `oars gate hook` prints (a note or a deny) is passed through, and only when it exits 0: an `oars` with no `gate hook` prints and exits 2, which would block the tool call, so that output is dropped.
|
|
9
|
+
* The trailing `exit 0` matters for the same reason.
|
|
10
|
+
*/
|
|
11
|
+
export declare const COMMAND = "command -v oars >/dev/null 2>&1 || exit 0; out=$(oars gate hook 2>/dev/null) && printf '%s' \"$out\"; exit 0";
|
|
12
|
+
/** The command an earlier build wrote, which discarded the hook's output. Still ours, so a rerun replaces it and an uninstall removes it. */
|
|
13
|
+
export declare const PREVIOUS_COMMAND = "command -v oars >/dev/null 2>&1 || exit 0; oars gate hook >/dev/null 2>&1; exit 0";
|
|
14
|
+
export interface GateInstallResult {
|
|
15
|
+
file: string;
|
|
16
|
+
/** False when the file already held exactly this entry: nothing was written. */
|
|
17
|
+
changed: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** Unparsable settings are refused by `readSettings` before anything is written. */
|
|
20
|
+
export declare const installGateHook: (path: string) => Promise<GateInstallResult>;
|
|
21
|
+
export interface GateUninstallResult {
|
|
22
|
+
file: string;
|
|
23
|
+
removed: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare const uninstallGateHook: (path: string) => Promise<GateUninstallResult>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// `oars gate install` and `oars gate uninstall`: one PreToolUse entry in Claude Code's user settings that runs `oars gate hook`. The entry is ours when its command is
|
|
2
|
+
// exactly `COMMAND` or `PREVIOUS_COMMAND`, so uninstall removes that entry and nothing else, and every other key and hook in the file is carried through as parsed JSON. The command calls the
|
|
3
|
+
// installed `oars`, never `npx` (which would fetch a package on the hook's clock), and does nothing when `oars` is not on the PATH, so a settings file that outlives the
|
|
4
|
+
// install cannot cost a session its tool calls.
|
|
5
|
+
import { chmod, lstat, mkdir, readlink, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, resolve } from 'node:path';
|
|
7
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
8
|
+
import { readSettings } from '../sessions/install.js';
|
|
9
|
+
/** The tools whose calls can cross: shell commands (both shells), skills, and every MCP tool. */
|
|
10
|
+
export const MATCHER = 'Bash|PowerShell|Skill|mcp__.*';
|
|
11
|
+
export const EVENT = 'PreToolUse';
|
|
12
|
+
/** Seconds the harness waits for the hook; the hook ends itself in under one, so this is only the ceiling for a cold node start. */
|
|
13
|
+
export const TIMEOUT_SECONDS = 5;
|
|
14
|
+
/**
|
|
15
|
+
* Run by the harness's shell (Git Bash on Windows). `command -v` is a shell builtin, so a missing `oars` costs a few milliseconds and no node start. The harness reads this command's stdout, so what
|
|
16
|
+
* `oars gate hook` prints (a note or a deny) is passed through, and only when it exits 0: an `oars` with no `gate hook` prints and exits 2, which would block the tool call, so that output is dropped.
|
|
17
|
+
* The trailing `exit 0` matters for the same reason.
|
|
18
|
+
*/
|
|
19
|
+
export const COMMAND = `command -v oars >/dev/null 2>&1 || exit 0; out=$(oars gate hook 2>/dev/null) && printf '%s' "$out"; exit 0`;
|
|
20
|
+
/** The command an earlier build wrote, which discarded the hook's output. Still ours, so a rerun replaces it and an uninstall removes it. */
|
|
21
|
+
export const PREVIOUS_COMMAND = 'command -v oars >/dev/null 2>&1 || exit 0; oars gate hook >/dev/null 2>&1; exit 0';
|
|
22
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
const isOwnHook = (hook) => isObject(hook) && (hook.command === COMMAND || hook.command === PREVIOUS_COMMAND);
|
|
24
|
+
const holdsOwn = (group) => isObject(group) && Array.isArray(group.hooks) && group.hooks.some(isOwnHook);
|
|
25
|
+
/** The group we write: our matcher, our one entry. */
|
|
26
|
+
const ownGroup = () => ({ matcher: MATCHER, hooks: [{ type: 'command', command: COMMAND, timeout: TIMEOUT_SECONDS }] });
|
|
27
|
+
/** A group of the operator's, with our entry taken out of it; `[]` when ours was all it held. */
|
|
28
|
+
const remainder = (group) => {
|
|
29
|
+
const rest = group.hooks.filter((hook) => !isOwnHook(hook));
|
|
30
|
+
return rest.length === 0 ? [] : [{ ...group, hooks: rest }];
|
|
31
|
+
};
|
|
32
|
+
/** Our group put in the place our entry already holds, or at the end. Placing it where it is keeps a rerun from moving it past a group someone added after it. */
|
|
33
|
+
const withOwn = (groups) => {
|
|
34
|
+
const first = groups.findIndex(holdsOwn);
|
|
35
|
+
if (first === -1)
|
|
36
|
+
return [...groups, ownGroup()];
|
|
37
|
+
return groups.flatMap((group, at) => {
|
|
38
|
+
if (!holdsOwn(group))
|
|
39
|
+
return [group];
|
|
40
|
+
const kept = remainder(group);
|
|
41
|
+
return at === first ? [...kept, ownGroup()] : kept;
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
const withoutOwn = (groups) => groups.flatMap((group) => (holdsOwn(group) ? remainder(group) : [group]));
|
|
45
|
+
/** Where a symlink that points at nothing yet would land, so the rename writes the file it names and leaves the link a link. `undefined` when `path` is not a symlink. */
|
|
46
|
+
const danglingTarget = async (path) => {
|
|
47
|
+
try {
|
|
48
|
+
return (await lstat(path)).isSymbolicLink() ? resolve(dirname(path), await readlink(path)) : undefined;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/** The file as it stands, followed through a symlink (a dotfile manager's), with its permission bits. `mode` is `undefined` when there is no file yet; `target` is then where it will land. */
|
|
55
|
+
const existing = async (path) => {
|
|
56
|
+
try {
|
|
57
|
+
const target = await realpath(path);
|
|
58
|
+
return { target, mode: (await stat(target)).mode & 0o777 };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
const target = await danglingTarget(path);
|
|
62
|
+
return target === undefined ? undefined : { target, mode: undefined };
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
/** Whole and atomic: a half-written settings file would take every other hook in it down too. The temp file sits beside the real target with the file's own mode (0600 when new: `env` can hold secrets), and the rename lands on the resolved path, so a symlinked settings file stays a symlink. The mode is set after the write because the umask masks the one `writeFile` is given; a failed rename leaves no temp file behind. */
|
|
66
|
+
const writeSettings = async (path, settings) => {
|
|
67
|
+
const found = await existing(path);
|
|
68
|
+
const target = found?.target ?? path;
|
|
69
|
+
await mkdir(dirname(target), { recursive: true });
|
|
70
|
+
const temp = `${target}.${String(process.pid)}.oars-gate.tmp`;
|
|
71
|
+
const mode = found?.mode ?? 0o600;
|
|
72
|
+
try {
|
|
73
|
+
await writeFile(temp, `${JSON.stringify(settings, null, 2)}
|
|
74
|
+
`, { mode });
|
|
75
|
+
await chmod(temp, mode);
|
|
76
|
+
await rename(temp, target);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
await rm(temp, { force: true });
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
// A `hooks` key, or a PreToolUse key under it, of another shape is the operator's and this version cannot merge into it: refuse it, and write nothing.
|
|
84
|
+
const hooksOf = (settings, path) => {
|
|
85
|
+
if (settings.hooks === undefined)
|
|
86
|
+
return {};
|
|
87
|
+
if (!isObject(settings.hooks))
|
|
88
|
+
throw new CliError(`"hooks" in ${path} is not an object; nothing was changed.`, ExitCode.USAGE, 'usage');
|
|
89
|
+
return settings.hooks;
|
|
90
|
+
};
|
|
91
|
+
const groupsOf = (hooks, path) => {
|
|
92
|
+
const groups = hooks[EVENT];
|
|
93
|
+
if (groups === undefined)
|
|
94
|
+
return [];
|
|
95
|
+
if (!Array.isArray(groups))
|
|
96
|
+
throw new CliError(`"hooks.${EVENT}" in ${path} is not a list; nothing was changed.`, ExitCode.USAGE, 'usage');
|
|
97
|
+
return groups;
|
|
98
|
+
};
|
|
99
|
+
/** Unparsable settings are refused by `readSettings` before anything is written. */
|
|
100
|
+
export const installGateHook = async (path) => {
|
|
101
|
+
const settings = await readSettings(path);
|
|
102
|
+
const before = JSON.stringify(settings);
|
|
103
|
+
const hooks = hooksOf(settings, path);
|
|
104
|
+
hooks[EVENT] = withOwn(groupsOf(hooks, path));
|
|
105
|
+
settings.hooks = hooks;
|
|
106
|
+
if (JSON.stringify(settings) === before)
|
|
107
|
+
return { file: path, changed: false };
|
|
108
|
+
await writeSettings(path, settings);
|
|
109
|
+
return { file: path, changed: true };
|
|
110
|
+
};
|
|
111
|
+
export const uninstallGateHook = async (path) => {
|
|
112
|
+
const settings = await readSettings(path);
|
|
113
|
+
const hooks = hooksOf(settings, path);
|
|
114
|
+
const groups = groupsOf(hooks, path);
|
|
115
|
+
if (!groups.some(holdsOwn))
|
|
116
|
+
return { file: path, removed: false };
|
|
117
|
+
const kept = withoutOwn(groups);
|
|
118
|
+
// An event this call emptied goes, and so does a `hooks` key it emptied; anything else the operator wrote stays where it was.
|
|
119
|
+
if (kept.length === 0)
|
|
120
|
+
Reflect.deleteProperty(hooks, EVENT);
|
|
121
|
+
else
|
|
122
|
+
hooks[EVENT] = kept;
|
|
123
|
+
settings.hooks = Object.keys(hooks).length === 0 ? undefined : hooks;
|
|
124
|
+
await writeSettings(path, settings);
|
|
125
|
+
return { file: path, removed: true };
|
|
126
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Crossing } from './checks.js';
|
|
2
|
+
/** Answers already found, so one folder and bin cost one walk however many calls ask. */
|
|
3
|
+
export type LocalBinMemo = Map<string, Promise<boolean>>;
|
|
4
|
+
/**
|
|
5
|
+
* The crossing as it should be recorded: `local-bin-run` for an unstated `npx` whose folder, or a parent's, holds the bin, else the crossing itself. A version, a call that chose
|
|
6
|
+
* its own folder or registry (`registryAsked` false), or an unknown folder leaves the question open, so the crossing stays a fetch-and-run.
|
|
7
|
+
*/
|
|
8
|
+
export declare const withLocalBin: (crossing: Crossing, cwd: string | null, memo: LocalBinMemo) => Promise<Crossing>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// `npx <name>` where the folder already has `node_modules/.bin/<name>` runs the project's own copy and fetches nothing. It is the common case (most main-session `npx` crossings
|
|
2
|
+
// whose folder exists), so it gets its own closed form, `local-bin-run`, and is not counted as a fetch-and-run pick. Live and replay both apply this one rule.
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
const LEVELS = 12;
|
|
6
|
+
/** A Windows install leaves `.cmd` and `.ps1` shims beside the bare name. */
|
|
7
|
+
const SHIMS = ['', '.cmd', '.ps1'];
|
|
8
|
+
const exists = (path) => stat(path).then(() => true, () => false);
|
|
9
|
+
/** The bin an `npx` name runs: the last segment of a scoped name. */
|
|
10
|
+
const binOf = (name) => name.slice(name.lastIndexOf('/') + 1);
|
|
11
|
+
const walk = async (cwd, bin) => {
|
|
12
|
+
let folder = cwd;
|
|
13
|
+
for (let level = 0; level < LEVELS; level += 1) {
|
|
14
|
+
for (const shim of SHIMS) {
|
|
15
|
+
if (await exists(join(folder, 'node_modules', '.bin', `${bin}${shim}`)))
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
const parent = dirname(folder);
|
|
19
|
+
if (parent === folder)
|
|
20
|
+
break;
|
|
21
|
+
folder = parent;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The crossing as it should be recorded: `local-bin-run` for an unstated `npx` whose folder, or a parent's, holds the bin, else the crossing itself. A version, a call that chose
|
|
27
|
+
* its own folder or registry (`registryAsked` false), or an unknown folder leaves the question open, so the crossing stays a fetch-and-run.
|
|
28
|
+
*/
|
|
29
|
+
export const withLocalBin = async (crossing, cwd, memo) => {
|
|
30
|
+
if (crossing.program !== 'npx' || crossing.form !== 'fetch-and-run' || crossing.versionForm !== 'unstated' || !crossing.registryAsked || cwd === null)
|
|
31
|
+
return crossing;
|
|
32
|
+
const key = `${cwd}\0${binOf(crossing.name)}`;
|
|
33
|
+
const known = memo.get(key) ?? walk(cwd, binOf(crossing.name));
|
|
34
|
+
memo.set(key, known);
|
|
35
|
+
return (await known) ? { ...crossing, form: 'local-bin-run' } : crossing;
|
|
36
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Policy } from './policy.js';
|
|
2
|
+
import type { Arm, LineId } from './record.js';
|
|
3
|
+
/** The facts a decision reads: what the registry answered, when the name was first published, and whether this session has already had a sight of the name with a reading (`noted`). */
|
|
4
|
+
export interface LineFacts {
|
|
5
|
+
state: 'found' | 'not-found';
|
|
6
|
+
created: string | null;
|
|
7
|
+
noted: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface Decision {
|
|
10
|
+
arm: Arm;
|
|
11
|
+
lineId: LineId;
|
|
12
|
+
kind: 'block' | 'note' | 'none';
|
|
13
|
+
text: string | null;
|
|
14
|
+
}
|
|
15
|
+
/** The only words the hook ever shows. Each states what the registry said and nothing about what to do. */
|
|
16
|
+
export declare const LINE_TABLE: Readonly<Record<Exclude<LineId, 'none'>, string>>;
|
|
17
|
+
export declare const lineText: (id: LineId, name: string, days: number) => string;
|
|
18
|
+
/** Silent unless a class fires. A block is for a name the registry answered as not found, only when the policy says `block`, and is never held back. */
|
|
19
|
+
export declare const decide: (policy: Policy, facts: LineFacts | undefined, now: number, random: () => number, name: string) => Decision;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DAY_MS } from './registry-state.js';
|
|
2
|
+
/** The only words the hook ever shows. Each states what the registry said and nothing about what to do. */
|
|
3
|
+
export const LINE_TABLE = {
|
|
4
|
+
'young-name-note': 'Registry note: {name} was first published {age} ago in the registry your package manager uses.',
|
|
5
|
+
'not-found-note': 'Registry note: {name} was not found in the registry your package manager uses.',
|
|
6
|
+
'not-found-block': 'Blocked by your gate policy: {name} was not found in the registry your package manager uses.',
|
|
7
|
+
};
|
|
8
|
+
const ageText = (days) => (days < 1 ? 'less than a day' : days === 1 ? '1 day' : `${String(days)} days`);
|
|
9
|
+
export const lineText = (id, name, days) => (id === 'none' ? '' : LINE_TABLE[id].replace('{name}', name).replace('{age}', ageText(days)));
|
|
10
|
+
const NONE = { arm: 'none', lineId: 'none', kind: 'none', text: null };
|
|
11
|
+
/** A note that would show, held back when the draw falls inside the share. The draw is spent on a note only. */
|
|
12
|
+
const note = (policy, lineId, name, days, random) => random() < policy.holdbackPercent / 100 ? { arm: 'held', lineId, kind: 'none', text: null } : { arm: 'shown', lineId, kind: 'note', text: lineText(lineId, name, days) };
|
|
13
|
+
/** Silent unless a class fires. A block is for a name the registry answered as not found, only when the policy says `block`, and is never held back. */
|
|
14
|
+
export const decide = (policy, facts, now, random, name) => {
|
|
15
|
+
if (facts === undefined)
|
|
16
|
+
return NONE;
|
|
17
|
+
if (facts.state === 'not-found') {
|
|
18
|
+
const action = policy.classes['not-found'];
|
|
19
|
+
if (action === 'block')
|
|
20
|
+
return { arm: 'none', lineId: 'not-found-block', kind: 'block', text: lineText('not-found-block', name, 0) };
|
|
21
|
+
return action === 'warn' && !facts.noted ? note(policy, 'not-found-note', name, 0, random) : NONE;
|
|
22
|
+
}
|
|
23
|
+
if (policy.classes['young-name'] !== 'warn' || facts.created === null || facts.noted)
|
|
24
|
+
return NONE;
|
|
25
|
+
const days = (now - Date.parse(facts.created)) / DAY_MS;
|
|
26
|
+
return days >= 0 && days < policy.youngDays ? note(policy, 'young-name-note', name, Math.floor(days), random) : NONE;
|
|
27
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Novelty } from './record.js';
|
|
2
|
+
export declare const INDEX_HEADER = "oars-gate-index 1";
|
|
3
|
+
export declare const indexFile: (storeDir: string) => string;
|
|
4
|
+
/** The entries of one crossing, as the lines the index keeps. `read` is the session's sight of the name that had a registry reading, which is what spends the session's one note. */
|
|
5
|
+
export interface Scopes {
|
|
6
|
+
fleet: string;
|
|
7
|
+
repository: string;
|
|
8
|
+
session: string;
|
|
9
|
+
read: string;
|
|
10
|
+
}
|
|
11
|
+
/** `key` is the class and name; the repository and session ids are as the caller knows them. */
|
|
12
|
+
export declare const scopesOf: (key: string, repository: string, session: string) => Scopes;
|
|
13
|
+
/**
|
|
14
|
+
* `ready`: read cleanly. `fresh`: no index and no record, so an empty one is right. `unseeded`: no index but a record exists, so what is new cannot be told without a
|
|
15
|
+
* scan; `oars gate replay` seeds it. `damaged`: it did not read cleanly, and nothing is written to it.
|
|
16
|
+
*/
|
|
17
|
+
export type KeptIndex = {
|
|
18
|
+
state: 'ready';
|
|
19
|
+
lines: ReadonlySet<string>;
|
|
20
|
+
} | {
|
|
21
|
+
state: 'fresh' | 'unseeded' | 'damaged';
|
|
22
|
+
};
|
|
23
|
+
export declare const loadIndex: (storeDir: string, record: string) => Promise<KeptIndex>;
|
|
24
|
+
export declare const noveltyOf: (index: KeptIndex, scopes: Scopes) => {
|
|
25
|
+
novelty: Novelty;
|
|
26
|
+
add: string[];
|
|
27
|
+
noted: boolean;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Appends the lines a crossing added. A `fresh` index starts with its header (a second header from a hook racing this one is skipped on read). A torn tail (a line cut off
|
|
31
|
+
* before its line break) gets one first, so the append is its own lines and the torn one costs only itself.
|
|
32
|
+
*/
|
|
33
|
+
export declare const keepIndex: (storeDir: string, index: KeptIndex, add: readonly string[]) => Promise<void>;
|
|
34
|
+
/** Writes the index from every line given plus the ones already kept, whole and atomic. Used by `replay`, which is not in a hurry. */
|
|
35
|
+
export declare const seedIndex: (storeDir: string, record: string, lines: Iterable<string>) => Promise<void>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The kept index behind the hook's `novelty`: which names have been seen in the whole record, in a repository, and in a session. The hook reads it instead of scanning
|
|
2
|
+
// `crossings.jsonl`, which a long-lived record would make slow. It holds hashes only (a name, a repository, or a session id is never written here), one per line, and a
|
|
3
|
+
// line is only ever appended, so two hooks running at once cannot lose each other's update. A file that does not read cleanly is `damaged`: the row says `not-read`.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { appendFile, mkdir, open, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
export const INDEX_HEADER = 'oars-gate-index 1';
|
|
8
|
+
export const indexFile = (storeDir) => join(storeDir, 'gate-index.txt');
|
|
9
|
+
const LINE = /^[frs][0-9a-f]{12}$/;
|
|
10
|
+
const TORN = /^(?:[frs][0-9a-f]{0,11})?$/;
|
|
11
|
+
const digest = (...parts) => createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 12);
|
|
12
|
+
/** `key` is the class and name; the repository and session ids are as the caller knows them. */
|
|
13
|
+
export const scopesOf = (key, repository, session) => ({
|
|
14
|
+
fleet: `f${digest(key)}`,
|
|
15
|
+
repository: `r${digest(repository, key)}`,
|
|
16
|
+
session: `s${digest(session, key)}`,
|
|
17
|
+
read: `s${digest(session, key, 'read')}`,
|
|
18
|
+
});
|
|
19
|
+
const notFound = (error) => error instanceof Error && Reflect.get(error, 'code') === 'ENOENT';
|
|
20
|
+
const hasRecord = (file) => stat(file).then((found) => found.size > 0, () => false);
|
|
21
|
+
const parseIndex = (text) => {
|
|
22
|
+
const parts = text.split('\n');
|
|
23
|
+
// The last part is empty after a complete line; anything else there is a line that was being appended, and says nothing yet.
|
|
24
|
+
parts.pop();
|
|
25
|
+
if (parts[0] !== INDEX_HEADER)
|
|
26
|
+
return { state: 'damaged' };
|
|
27
|
+
const lines = new Set();
|
|
28
|
+
for (const [at, line] of parts.entries()) {
|
|
29
|
+
if (line === INDEX_HEADER)
|
|
30
|
+
continue;
|
|
31
|
+
// A torn line that the next append closed with a line break: a cut-off hash, or an empty line when two appends raced on the same torn tail. Skipped, and only ever before a later line.
|
|
32
|
+
if (at < parts.length - 1 && TORN.test(line))
|
|
33
|
+
continue;
|
|
34
|
+
if (!LINE.test(line))
|
|
35
|
+
return { state: 'damaged' };
|
|
36
|
+
lines.add(line);
|
|
37
|
+
}
|
|
38
|
+
return { state: 'ready', lines };
|
|
39
|
+
};
|
|
40
|
+
export const loadIndex = async (storeDir, record) => {
|
|
41
|
+
try {
|
|
42
|
+
return parseIndex(await readFile(indexFile(storeDir), 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (!notFound(error))
|
|
46
|
+
return { state: 'damaged' };
|
|
47
|
+
return (await hasRecord(record)) ? { state: 'unseeded' } : { state: 'fresh' };
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
export const noveltyOf = (index, scopes) => {
|
|
51
|
+
// With no readable index there is no telling whether the name was noted, and no line can be kept to say so: it counts as noted, so no note shows until a replay seeds the index. A block does not read `noted`.
|
|
52
|
+
if (index.state === 'damaged' || index.state === 'unseeded')
|
|
53
|
+
return { novelty: 'not-read', add: [], noted: true };
|
|
54
|
+
const held = index.state === 'ready' ? index.lines : new Set();
|
|
55
|
+
const add = [scopes.fleet, scopes.repository, scopes.session].filter((line) => !held.has(line));
|
|
56
|
+
const noted = held.has(scopes.read);
|
|
57
|
+
if (!held.has(scopes.fleet))
|
|
58
|
+
return { novelty: 'fleet', add, noted };
|
|
59
|
+
if (!held.has(scopes.repository))
|
|
60
|
+
return { novelty: 'repository', add, noted };
|
|
61
|
+
return { novelty: held.has(scopes.session) ? 'none' : 'session', add, noted };
|
|
62
|
+
};
|
|
63
|
+
/** True when the file's last byte is a line break, so an append starts a line of its own. The caller has read the header, so the file is not empty. */
|
|
64
|
+
const endsClean = async (file) => {
|
|
65
|
+
const handle = await open(file, 'r');
|
|
66
|
+
try {
|
|
67
|
+
const { size } = await handle.stat();
|
|
68
|
+
const last = Buffer.alloc(1);
|
|
69
|
+
await handle.read(last, 0, 1, size - 1);
|
|
70
|
+
return last[0] === 0x0a;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await handle.close();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Appends the lines a crossing added. A `fresh` index starts with its header (a second header from a hook racing this one is skipped on read). A torn tail (a line cut off
|
|
78
|
+
* before its line break) gets one first, so the append is its own lines and the torn one costs only itself.
|
|
79
|
+
*/
|
|
80
|
+
export const keepIndex = async (storeDir, index, add) => {
|
|
81
|
+
if (add.length === 0 || (index.state !== 'ready' && index.state !== 'fresh'))
|
|
82
|
+
return;
|
|
83
|
+
await mkdir(storeDir, { recursive: true });
|
|
84
|
+
const file = indexFile(storeDir);
|
|
85
|
+
const lead = index.state === 'fresh' ? `${INDEX_HEADER}\n` : (await endsClean(file)) ? '' : '\n';
|
|
86
|
+
await appendFile(file, `${lead}${add.join('\n')}\n`, { mode: 0o600 });
|
|
87
|
+
};
|
|
88
|
+
/** Writes the index from every line given plus the ones already kept, whole and atomic. Used by `replay`, which is not in a hurry. */
|
|
89
|
+
export const seedIndex = async (storeDir, record, lines) => {
|
|
90
|
+
const kept = await loadIndex(storeDir, record);
|
|
91
|
+
const all = new Set(kept.state === 'ready' ? kept.lines : []);
|
|
92
|
+
for (const line of lines)
|
|
93
|
+
all.add(line);
|
|
94
|
+
await mkdir(storeDir, { recursive: true });
|
|
95
|
+
const file = indexFile(storeDir);
|
|
96
|
+
const temp = `${file}.${String(process.pid)}.tmp`;
|
|
97
|
+
await writeFile(temp, `${[INDEX_HEADER, ...all].join('\n')}\n`, { mode: 0o600 });
|
|
98
|
+
await rename(temp, file);
|
|
99
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The classes the policy sets. Each is something a row can already tell: a registry name first published recently, and a name the registry does not hold. */
|
|
2
|
+
export declare const POLICY_CLASSES: readonly ["young-name", "not-found"];
|
|
3
|
+
export type PolicyClass = (typeof POLICY_CLASSES)[number];
|
|
4
|
+
export declare const POLICY_ACTIONS: readonly ["warn", "block", "off"];
|
|
5
|
+
export type PolicyAction = (typeof POLICY_ACTIONS)[number];
|
|
6
|
+
export interface Policy {
|
|
7
|
+
classes: Readonly<Record<PolicyClass, PolicyAction>>;
|
|
8
|
+
/** A name first published fewer days ago than this is a young name. */
|
|
9
|
+
youngDays: number;
|
|
10
|
+
/** The share, 0 to 100, of notes that would show and do not. Never applied to a block. */
|
|
11
|
+
holdbackPercent: number;
|
|
12
|
+
}
|
|
13
|
+
export declare const DEFAULT_POLICY: Policy;
|
|
14
|
+
/** Where a row's policy came from: no file, a file that fit, or a file that was there and did not (so the defaults stood). */
|
|
15
|
+
export declare const POLICY_SOURCES: readonly ["default", "file", "invalid"];
|
|
16
|
+
export type PolicySource = (typeof POLICY_SOURCES)[number];
|
|
17
|
+
export interface PolicyRead {
|
|
18
|
+
policy: Policy;
|
|
19
|
+
source: PolicySource;
|
|
20
|
+
}
|
|
21
|
+
export declare const policyPath: (storeDir: string) => string;
|
|
22
|
+
/** Never throws. A file that is not there is the defaults; one that is there and cannot be read or does not fit is the defaults too, and says `invalid`. */
|
|
23
|
+
export declare const loadPolicy: (storeDir: string) => Promise<PolicyRead>;
|
|
24
|
+
/** What `oars gate policy` prints. */
|
|
25
|
+
export declare const formatPolicy: (read: PolicyRead, file: string) => string;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The gate policy file: what the hook may say, per class, in a closed vocabulary. The operator writes it; nothing here writes it. A missing file reads as the defaults, and so
|
|
2
|
+
// does one that cannot be read or does not fit the shape: the hook never throws over it, and the row records which of the three it was, so `oars gate report` can count them.
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/** The classes the policy sets. Each is something a row can already tell: a registry name first published recently, and a name the registry does not hold. */
|
|
6
|
+
export const POLICY_CLASSES = ['young-name', 'not-found'];
|
|
7
|
+
export const POLICY_ACTIONS = ['warn', 'block', 'off'];
|
|
8
|
+
/** What a young name can be set to: a note or nothing. A block is for a name the registry does not hold, since only that is a fact the registry answers by itself. */
|
|
9
|
+
const ALLOWED = { 'young-name': ['warn', 'off'], 'not-found': POLICY_ACTIONS };
|
|
10
|
+
export const DEFAULT_POLICY = { classes: { 'young-name': 'warn', 'not-found': 'warn' }, youngDays: 30, holdbackPercent: 10 };
|
|
11
|
+
/** Where a row's policy came from: no file, a file that fit, or a file that was there and did not (so the defaults stood). */
|
|
12
|
+
export const POLICY_SOURCES = ['default', 'file', 'invalid'];
|
|
13
|
+
export const policyPath = (storeDir) => join(storeDir, 'gate-policy.json');
|
|
14
|
+
const KEYS = new Set(['v', 'classes', 'youngDays', 'holdbackPercent']);
|
|
15
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
+
const integerIn = (value, low, high) => (typeof value === 'number' && Number.isInteger(value) && value >= low && value <= high ? value : undefined);
|
|
17
|
+
/** `undefined` for anything outside the closed vocabulary: an unknown key, class, or action, a number out of range, or another version. */
|
|
18
|
+
const fit = (parsed) => {
|
|
19
|
+
if (!isObject(parsed) || Object.keys(parsed).some((key) => !KEYS.has(key)))
|
|
20
|
+
return undefined;
|
|
21
|
+
const version = Reflect.get(parsed, 'v');
|
|
22
|
+
if (version !== undefined && version !== 1)
|
|
23
|
+
return undefined;
|
|
24
|
+
const given = Reflect.get(parsed, 'classes');
|
|
25
|
+
if (given !== undefined && (!isObject(given) || Object.keys(given).some((key) => !POLICY_CLASSES.some((known) => known === key))))
|
|
26
|
+
return undefined;
|
|
27
|
+
const classes = { ...DEFAULT_POLICY.classes };
|
|
28
|
+
for (const cls of POLICY_CLASSES) {
|
|
29
|
+
const action = isObject(given) ? Reflect.get(given, cls) : undefined;
|
|
30
|
+
if (action === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
const allowed = ALLOWED[cls].find((one) => one === action);
|
|
33
|
+
if (allowed === undefined)
|
|
34
|
+
return undefined;
|
|
35
|
+
classes[cls] = allowed;
|
|
36
|
+
}
|
|
37
|
+
const days = Reflect.get(parsed, 'youngDays');
|
|
38
|
+
const share = Reflect.get(parsed, 'holdbackPercent');
|
|
39
|
+
const youngDays = days === undefined ? DEFAULT_POLICY.youngDays : integerIn(days, 1, 365);
|
|
40
|
+
const holdbackPercent = share === undefined ? DEFAULT_POLICY.holdbackPercent : integerIn(share, 0, 100);
|
|
41
|
+
return youngDays === undefined || holdbackPercent === undefined ? undefined : { classes, youngDays, holdbackPercent };
|
|
42
|
+
};
|
|
43
|
+
/** Never throws. A file that is not there is the defaults; one that is there and cannot be read or does not fit is the defaults too, and says `invalid`. */
|
|
44
|
+
export const loadPolicy = async (storeDir) => {
|
|
45
|
+
let body;
|
|
46
|
+
try {
|
|
47
|
+
body = await readFile(policyPath(storeDir), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const code = isObject(error) ? Reflect.get(error, 'code') : undefined;
|
|
51
|
+
return { policy: DEFAULT_POLICY, source: code === 'ENOENT' ? 'default' : 'invalid' };
|
|
52
|
+
}
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
// Windows PowerShell 5.1 writes UTF-8 with a byte order mark, and `JSON.parse` refuses it: an operator's `block` would silently not apply.
|
|
56
|
+
parsed = JSON.parse(body.charCodeAt(0) === 0xfeff ? body.slice(1) : body);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return { policy: DEFAULT_POLICY, source: 'invalid' };
|
|
60
|
+
}
|
|
61
|
+
const policy = fit(parsed);
|
|
62
|
+
return policy === undefined ? { policy: DEFAULT_POLICY, source: 'invalid' } : { policy, source: 'file' };
|
|
63
|
+
};
|
|
64
|
+
/** What `oars gate policy` prints. */
|
|
65
|
+
export const formatPolicy = (read, file) => {
|
|
66
|
+
const origin = read.source === 'file' ? `Policy in force: ${file}` : read.source === 'default' ? `Policy in force: defaults (no file at ${file})` : `Policy in force: defaults (${file} is unreadable or invalid, so it is ignored)`;
|
|
67
|
+
return [
|
|
68
|
+
origin,
|
|
69
|
+
...POLICY_CLASSES.map((cls) => `${cls}: ${read.policy.classes[cls]}`),
|
|
70
|
+
`Young name: first published within ${String(read.policy.youngDays)} days`,
|
|
71
|
+
`Holdback: ${String(read.policy.holdbackPercent)}% of notes are not shown`,
|
|
72
|
+
].join('\n');
|
|
73
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { PolicySource } from './policy.js';
|
|
2
|
+
import { CROSSING_CLASSES, CROSSING_FORMS, type CrossingClass, type CrossingForm, type UnreadShape, type VersionForm } from './checks.js';
|
|
3
|
+
export declare const GATE_VERSION = 1;
|
|
4
|
+
/** Where a row came from. A replay row is read from a transcript, a hook row was written as the call crossed, and `both` is a hook row a replay then filled in. */
|
|
5
|
+
export declare const VIAS: readonly ["hook", "replay", "both"];
|
|
6
|
+
export type Via = (typeof VIAS)[number];
|
|
7
|
+
/** `not-read` on a field says no one has read it for this row yet: a hook row has no transcript, so the fields a transcript gives read `not-read` until a replay fills them. */
|
|
8
|
+
export declare const NOT_READ = "not-read";
|
|
9
|
+
/** Every program a crossing can name: the package managers and runners the gate reads, their `python -m` forms, and the `mcp` and `skill` tools. */
|
|
10
|
+
export declare const PROGRAMS: readonly ["npm", "pnpm", "yarn", "bun", "npx", "pnpx", "bunx", "pip", "python -m pip", "python -m pipx", "python -m uv", "uv", "uvx", "pipx", "poetry", "pdm", "rye", "pipenv", "conda", "mamba", "micromamba", "mcp", "skill"];
|
|
11
|
+
export type Program = (typeof PROGRAMS)[number];
|
|
12
|
+
export declare const ORIGINS: readonly ["prompt", "repo-file", "web", "tool-output", "skill", "harness", "none-seen", "not-read"];
|
|
13
|
+
export type Origin = (typeof ORIGINS)[number];
|
|
14
|
+
export declare const NOVELTIES: readonly ["fleet", "repository", "session", "none", "not-read"];
|
|
15
|
+
export type Novelty = (typeof NOVELTIES)[number];
|
|
16
|
+
export declare const DECIDERS: readonly ["operator", "agent", "not-read"];
|
|
17
|
+
export type Decider = (typeof DECIDERS)[number];
|
|
18
|
+
export declare const PERMISSIONS: readonly ["default", "acceptEdits", "plan", "bypassPermissions", "dontAsk", "auto", "other", "unknown"];
|
|
19
|
+
export type Permission = (typeof PERMISSIONS)[number];
|
|
20
|
+
export declare const STATUSES: readonly ["ok", "error", "refused", "none", "not-read"];
|
|
21
|
+
export type Status = (typeof STATUSES)[number];
|
|
22
|
+
export declare const NEXTS: readonly ["proceeded", "retried", "routed-around", "changed-name", "stopped", "no-result", "not-read"];
|
|
23
|
+
export type Next = (typeof NEXTS)[number];
|
|
24
|
+
export declare const MISSES: readonly ["none", "not-found", "not-read"];
|
|
25
|
+
export type Miss = (typeof MISSES)[number];
|
|
26
|
+
export declare const AGAINST_LATEST: readonly ["not-read", "latest", "not-latest", "range", "unstated", "unknown"];
|
|
27
|
+
export type AgainstLatest = (typeof AGAINST_LATEST)[number];
|
|
28
|
+
export declare const REGISTRY_STATES: readonly ["not-read", "found", "not-found", "unreachable"];
|
|
29
|
+
export type RegistryState = (typeof REGISTRY_STATES)[number];
|
|
30
|
+
export declare const REGISTRY_AGES: readonly ["not-read", "after-crossing", "under-7-days", "under-30-days", "under-1-year", "older", "unknown"];
|
|
31
|
+
export type RegistryAge = (typeof REGISTRY_AGES)[number];
|
|
32
|
+
export declare const DEPTHS: readonly ["main", "subagent"];
|
|
33
|
+
export type Depth = (typeof DEPTHS)[number];
|
|
34
|
+
/** Whether a note that would show did (`shown`), was held back by the policy's holdback (`held`), or there was none to show or the line was a block (`none`). A replay shows nothing, so it reads `none`. */
|
|
35
|
+
export declare const ARMS: readonly ["none", "shown", "held"];
|
|
36
|
+
export type Arm = (typeof ARMS)[number];
|
|
37
|
+
/** The fixed ids of the lines the hook can show; the words are in `notes.ts`. `none` is no line shown, a held note included. */
|
|
38
|
+
export declare const LINE_IDS: readonly ["none", "young-name-note", "not-found-note", "not-found-block"];
|
|
39
|
+
export type LineId = (typeof LINE_IDS)[number];
|
|
40
|
+
export interface CrossingRecord {
|
|
41
|
+
kind: 'crossing';
|
|
42
|
+
v: number;
|
|
43
|
+
/** Absent in a row an earlier build wrote: those were all replays. */
|
|
44
|
+
via?: Via;
|
|
45
|
+
day: string | null;
|
|
46
|
+
class: CrossingClass;
|
|
47
|
+
form: CrossingForm;
|
|
48
|
+
program: Program;
|
|
49
|
+
versionForm: VersionForm;
|
|
50
|
+
statusReports: boolean;
|
|
51
|
+
origin: Origin;
|
|
52
|
+
novelty: Novelty;
|
|
53
|
+
decidedBy: Decider;
|
|
54
|
+
permission: Permission;
|
|
55
|
+
status: Status;
|
|
56
|
+
next: Next;
|
|
57
|
+
removedLater: boolean | typeof NOT_READ;
|
|
58
|
+
depth: Depth;
|
|
59
|
+
miss: Miss;
|
|
60
|
+
againstLatest: AgainstLatest;
|
|
61
|
+
registryState: RegistryState;
|
|
62
|
+
registryAge: RegistryAge;
|
|
63
|
+
arm: Arm;
|
|
64
|
+
lineShown: LineId;
|
|
65
|
+
/** The line a held note would have shown. Recorded apart from `lineShown` (which stays `none` when held) so the arms can be compared line by line; a row cannot be backfilled. */
|
|
66
|
+
lineHeld: LineId;
|
|
67
|
+
/** Which policy stood when the hook wrote the row. Absent in a replay row and in a row an earlier build wrote: no policy was read. */
|
|
68
|
+
policy?: PolicySource;
|
|
69
|
+
session: string;
|
|
70
|
+
agent: string | null;
|
|
71
|
+
toolUseId: string | null;
|
|
72
|
+
ts: string | null;
|
|
73
|
+
name: string;
|
|
74
|
+
version: string | null;
|
|
75
|
+
}
|
|
76
|
+
/** The run's own books, so `report` can say what this replay read and could not. Counts and a day: class P. */
|
|
77
|
+
export interface RunRow {
|
|
78
|
+
kind: 'run';
|
|
79
|
+
v: number;
|
|
80
|
+
day: string;
|
|
81
|
+
transcripts: number;
|
|
82
|
+
unreadable: number;
|
|
83
|
+
badLines: number;
|
|
84
|
+
calls: number;
|
|
85
|
+
crossings: number;
|
|
86
|
+
unread: Partial<Record<UnreadShape, number>>;
|
|
87
|
+
registryRead: boolean;
|
|
88
|
+
}
|
|
89
|
+
/** One kept registry answer, the cache of the `--registry-state` option. Held here so a replay writes one file. `name`, `latest`, and `created` are class A; `created` is a full timestamp, which class P cannot hold. */
|
|
90
|
+
export interface RegistryRow {
|
|
91
|
+
kind: 'registry';
|
|
92
|
+
v: number;
|
|
93
|
+
day: string;
|
|
94
|
+
/** When the answer was read, to the millisecond; a row an earlier build wrote has none. A not-found answer is trusted for minutes, not days. */
|
|
95
|
+
ts?: string;
|
|
96
|
+
/** Which folder the answer came from (`folderKey`): a folder's own config can name another registry, so a kept answer answers only that folder. A row with none is not fresh. Class A. */
|
|
97
|
+
folder?: string;
|
|
98
|
+
class: CrossingClass;
|
|
99
|
+
state: 'found' | 'not-found';
|
|
100
|
+
created: string | null;
|
|
101
|
+
name: string;
|
|
102
|
+
latest: string | null;
|
|
103
|
+
}
|
|
104
|
+
export type Row = CrossingRecord | RunRow | RegistryRow;
|
|
105
|
+
/** A truncated SHA-256 of the folder an ask ran in, so a kept answer names its folder without storing the path. */
|
|
106
|
+
export declare const folderKey: (cwd: string) => string;
|
|
107
|
+
/** The harness's permission mode, in the closed vocabulary: a mode this version does not know reads `other`. */
|
|
108
|
+
export declare const permissionOf: (value: string) => Permission;
|
|
109
|
+
/** A line this version cannot read is skipped: a torn tail or a newer row kind says nothing about the rest. */
|
|
110
|
+
export declare const parseRows: (text: string) => Row[];
|
|
111
|
+
export { CROSSING_CLASSES, CROSSING_FORMS };
|
|
112
|
+
/**
|
|
113
|
+
* Every crossing once. The rows of `replayed` are replay rows and the rows of `live` are hook rows (or merged ones); rows with the same session, tool use id, class, and
|
|
114
|
+
* name are one row, whether two sources saw it or one wrote it twice, so the same name twice in one call counts once. A row with no tool use id cannot be paired and stays.
|
|
115
|
+
*/
|
|
116
|
+
export declare const mergeCrossings: (replayed: readonly CrossingRecord[], live: readonly CrossingRecord[]) => CrossingRecord[];
|
|
117
|
+
/** The crossings of a file's rows with each counted once: replay rows and hook rows of the same call become one. */
|
|
118
|
+
export declare const crossingsOf: (rows: readonly Row[]) => CrossingRecord[];
|
|
119
|
+
/** The hook-written rows (and merged ones) of a file, which a replay keeps when it rewrites the rest. */
|
|
120
|
+
export declare const liveCrossings: (rows: readonly Row[]) => CrossingRecord[];
|