dsh-baize-rules 0.1.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/lib/command.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Thin dsh adapter for the `/rules` command: feeds the live `view` + current
3
+ * `defaultScope` into the dependency-free decision core (`core.ts`), then
4
+ * persists the resulting `nextView` (global → disk, session → memory) and any
5
+ * `/rules scope` default change.
6
+ *
7
+ * @module dsh-baize-rules/command
8
+ */
9
+ import { runCommand } from "./core.js";
10
+ import { view, writeGlobal, writeSession, readProject, writeProject } from "./store.js";
11
+ /** The registered handler contract: adapt command input → core decision → persist. */
12
+ export async function handle(ctx, invocation, runtime) {
13
+ const cwd = invocation.agent.session?.header?.cwd ?? '';
14
+ const v = await view(ctx, invocation.agent, { globalRulesPath: runtime.globalRulesPath });
15
+ const viewWithProject = cwd ? { ...v, project: await readProject(ctx, cwd) } : v;
16
+ const out = runCommand({
17
+ raw: invocation.rawInput,
18
+ view: viewWithProject,
19
+ defaultScope: runtime.getScope(),
20
+ });
21
+ if (out.nextView !== undefined) {
22
+ await writeGlobal(ctx, { globalRulesPath: runtime.globalRulesPath }, out.nextView.global);
23
+ await writeSession(ctx, invocation.agent.id, out.nextView.session);
24
+ if (cwd)
25
+ await writeProject(ctx, cwd, out.nextView.project ?? []);
26
+ }
27
+ if (out.defaultScope !== undefined)
28
+ runtime.setScope(out.defaultScope);
29
+ return out.ok ? { kind: 'success', text: out.text } : { kind: 'error', text: out.text };
30
+ }
package/lib/core.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Dependency-free rules-command core: parsing, scope resolution, and CRUD
3
+ * application over a {@link RuleView}. No dsh runtime needed, so it is testable
4
+ * in the Loop 0 harness (`pnpm test`). The dsh-facing adapter (`command.ts`)
5
+ * feeds in the live `view` + `defaultScope` and persists `nextView`/`defaultScope`.
6
+ *
7
+ * @module dsh-baize-rules/core
8
+ */
9
+ export const RULE_SCOPES = ['global', 'session', 'project'];
10
+ const SCOPE_SET = new Set(RULE_SCOPES);
11
+ /** Verbs where a trailing scope token is a *target* to modify, not an argument. */
12
+ const SCOPE_MODIFIER_VERBS = new Set(['add', 'remove', 'enable', 'disable']);
13
+ const USAGE = 'Usage: /rules [list|add <text>|remove <id>|edit <id> <text>|enable|disable <id>|scope <global|session|project>|clear <scope>|export]';
14
+ /** Parse a `/rules` line into a verb + args, isolating an explicit scope keyword.
15
+ * Scope is accepted as a **leading** token (`/rules global add …`) or a
16
+ * **trailing** token (`/rules add … global`, only the LAST arg qualifies),
17
+ * so a rule whose text merely contains "global" is never misread as a scope. */
18
+ export function parseCommand(raw) {
19
+ const tokens = raw.trim().split(/\s+/).filter(Boolean);
20
+ if (tokens.length === 0)
21
+ return { verb: undefined, args: [] };
22
+ let verb;
23
+ let scope;
24
+ let rest;
25
+ if (SCOPE_SET.has(tokens[0])) {
26
+ scope = tokens[0];
27
+ verb = tokens[1];
28
+ rest = tokens.slice(2);
29
+ }
30
+ else {
31
+ verb = tokens[0];
32
+ rest = tokens.slice(1);
33
+ // Strip a trailing scope only for verbs that take a *target* scope modifier,
34
+ // never for `scope`/`clear` where the scope is the argument itself.
35
+ if (verb !== undefined && SCOPE_MODIFIER_VERBS.has(verb) && rest.length > 0
36
+ && SCOPE_SET.has(rest[rest.length - 1])) {
37
+ scope = rest[rest.length - 1];
38
+ rest = rest.slice(0, -1);
39
+ }
40
+ }
41
+ return { verb, args: rest.filter(Boolean), scope };
42
+ }
43
+ /** Explicit scope wins, else the caller's default. */
44
+ export function resolveScope(scope, fallback) {
45
+ return scope ?? fallback;
46
+ }
47
+ /** Mint a fully-specified rule (dependency-free; uses global `crypto.randomUUID`). */
48
+ export function newRule(text, now = Date.now()) {
49
+ return {
50
+ id: crypto.randomUUID(),
51
+ text,
52
+ enabled: true,
53
+ createdAt: now,
54
+ updatedAt: now,
55
+ };
56
+ }
57
+ /** Return a new view with one rule appended to a scope. */
58
+ function listOf(view, scope) {
59
+ return scope === 'global' ? view.global : scope === 'project' ? (view.project ?? []) : view.session;
60
+ }
61
+ function withList(view, scope, list) {
62
+ if (scope === 'global')
63
+ return { ...view, global: [...list] };
64
+ if (scope === 'project')
65
+ return { ...view, project: [...list] };
66
+ return { ...view, session: [...list] };
67
+ }
68
+ export function addRule(view, scope, rule) {
69
+ return withList(view, scope, [...listOf(view, scope), rule]);
70
+ }
71
+ /** Return a new view with a rule removed from one scope. */
72
+ export function removeRule(view, scope, id) {
73
+ return withList(view, scope, listOf(view, scope).filter(rule => rule.id !== id));
74
+ }
75
+ /** Mutate a copy of a scope's rules for the given id; returns whether found. */
76
+ export function mutate(view, scope, id, fn) {
77
+ return withList(view, scope, listOf(view, scope).map(rule => rule.id === id ? fn(rule) : rule));
78
+ }
79
+ /** Render a compact human-readable list of the active rules. */
80
+ export function formatList(view) {
81
+ const line = (rule) => `[${rule.id.slice(0, 8)}] ${rule.enabled ? '' : '(disabled) '}${rule.text}`;
82
+ const parts = [];
83
+ if (view.project && view.project.length > 0) {
84
+ parts.push('Project:');
85
+ parts.push(...view.project.map(line));
86
+ }
87
+ if (view.global.length > 0) {
88
+ parts.push('Global:');
89
+ parts.push(...view.global.map(line));
90
+ }
91
+ if (view.session.length > 0) {
92
+ parts.push('Session:');
93
+ parts.push(...view.session.map(line));
94
+ }
95
+ return parts.length === 0 ? 'No active rules.' : parts.join('\n');
96
+ }
97
+ /** Apply one parsed `/rules` command to the given view + default scope. */
98
+ export function runCommand(input) {
99
+ const { verb, args, scope } = parseCommand(input.raw);
100
+ if (verb === undefined || verb === 'list') {
101
+ return { ok: true, text: formatList(input.view) };
102
+ }
103
+ const chosen = resolveScope(scope, input.defaultScope);
104
+ switch (verb) {
105
+ case 'add': {
106
+ const text = args.join(' ');
107
+ if (text.length === 0) {
108
+ return { ok: false, text: USAGE };
109
+ }
110
+ return {
111
+ ok: true,
112
+ text: `Added rule to ${chosen}: ${text}`,
113
+ nextView: addRule(input.view, chosen, newRule(text)),
114
+ };
115
+ }
116
+ case 'remove': {
117
+ const id = args[0] ?? '';
118
+ if (id.length === 0)
119
+ return { ok: false, text: USAGE };
120
+ return {
121
+ ok: true,
122
+ text: `Removed rule ${id} from ${chosen}.`,
123
+ nextView: removeRule(input.view, chosen, id),
124
+ };
125
+ }
126
+ case 'enable':
127
+ case 'disable': {
128
+ const id = args[0] ?? '';
129
+ if (id.length === 0)
130
+ return { ok: false, text: USAGE };
131
+ const enable = verb === 'enable';
132
+ const next = mutate(input.view, chosen, id, rule => ({ ...rule, enabled: enable, updatedAt: Date.now() }));
133
+ return { ok: true, text: `${enable ? 'Enabled' : 'Disabled'} rule ${id}.`, nextView: next };
134
+ }
135
+ case 'edit': {
136
+ const id = args[0] ?? '';
137
+ const text = args.slice(1).join(' ');
138
+ if (id.length === 0 || text.length === 0)
139
+ return { ok: false, text: USAGE };
140
+ const list = listOf(input.view, chosen);
141
+ if (!list.some(rule => rule.id === id))
142
+ return { ok: false, text: `Rule ${id} not found.` };
143
+ const next = mutate(input.view, chosen, id, rule => ({ ...rule, text, updatedAt: Date.now() }));
144
+ return { ok: true, text: `Updated rule ${id}.`, nextView: next };
145
+ }
146
+ case 'scope': {
147
+ const nextScope = args[0];
148
+ if (nextScope === undefined || !SCOPE_SET.has(nextScope))
149
+ return { ok: false, text: USAGE };
150
+ return { ok: true, text: `Default scope is now ${nextScope}.`, defaultScope: nextScope };
151
+ }
152
+ case 'clear': {
153
+ const clearScope = args[0];
154
+ if (clearScope === undefined || !SCOPE_SET.has(clearScope))
155
+ return { ok: false, text: USAGE };
156
+ const next = clearScope === 'global' ? { ...input.view, global: [] } : { ...input.view, session: [] };
157
+ return { ok: true, text: `Cleared ${clearScope} rules.`, nextView: next };
158
+ }
159
+ case 'export': {
160
+ return { ok: true, text: JSON.stringify({ global: input.view.global, session: input.view.session }, null, 2) };
161
+ }
162
+ default:
163
+ return { ok: false, text: USAGE };
164
+ }
165
+ }
package/lib/index.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * User-set session/global must-do and must-not requirements injected at
3
+ * conversation start. Named for 白泽 (Baize), the beast that knows all and
4
+ * distinguishes right from wrong — hence the must/mustNot framing.
5
+ *
6
+ * @module dsh-baize-rules
7
+ */
8
+ import z from '@deepseek-ai/schemastery';
9
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
10
+ import { renderDigest, renderRules } from "./rules.js";
11
+ import { view } from "./store.js";
12
+ import { handle } from "./command.js";
13
+ import { registerRulesApi } from "./api.js";
14
+ /** Cordis plugin name used by loader diagnostics + the injected message source. */
15
+ export const name = 'baize-rules';
16
+ /** Services granted on `ctx` by cordis before `apply` runs. We use `ctx.agents`
17
+ * (agent-plane scoping), `ctx.commands` (register /baize-rules), `ctx.fs`
18
+ * (persist global + session rule files), and `ctx.webServer` (rules panel API);
19
+ * each must be declared in `inject`. */
20
+ export const inject = ['agents', 'commands', 'fs', 'webServer', 'sessions'];
21
+ /** Schemastery validation for {@link Config}. */
22
+ export const Config = z.object({
23
+ scope: z.union([z.const('global'), z.const('session')]),
24
+ maxBytes: z.number().required(),
25
+ globalRulesPath: z.string(),
26
+ injectAtEveryStep: z.boolean(),
27
+ });
28
+ /** Per-session digest of the last injected rules, used to suppress duplicate injection. */
29
+ const lastInjected = /* @__PURE__ */ new WeakMap();
30
+ /**
31
+ * Register a prepended pre-step listener that injects the rendered rules as a
32
+ * durable user message on conversation start, plus the `/rules` command.
33
+ * @param ctx - plugin context; listener and command dispose with it.
34
+ * @param config - scope, budget, and redundancy policy.
35
+ */
36
+ export function apply(ctx, config) {
37
+ // `/rules scope` mutates this process-visible default, so the command and the
38
+ // pre-step view always read the current choice.
39
+ const mutableScope = { scope: config.scope };
40
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
41
+ const decision = await next();
42
+ if (decision.kind === 'reject' || signal.aborted)
43
+ return decision;
44
+ const v = await view(ctx, agent, config);
45
+ const text = renderRules(v, config.maxBytes);
46
+ if (text === undefined)
47
+ return decision;
48
+ const digest = await renderDigest(v, config.maxBytes);
49
+ const previous = lastInjected.get(agent.session);
50
+ if (!config.injectAtEveryStep && previous !== undefined && previous === digest)
51
+ return decision;
52
+ lastInjected.set(agent.session, digest ?? '');
53
+ return {
54
+ kind: 'enter',
55
+ messages: [
56
+ ...decision.messages,
57
+ createUserMessage({
58
+ content: [{ type: 'text', text }],
59
+ source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
60
+ }),
61
+ ],
62
+ };
63
+ }, { prepend: true });
64
+ ctx.effect(function* () {
65
+ const runtime = {
66
+ globalRulesPath: config.globalRulesPath,
67
+ getScope: () => mutableScope.scope,
68
+ setScope: (scope) => { mutableScope.scope = scope; },
69
+ };
70
+ yield ctx.commands.register({
71
+ name: 'baize-rules',
72
+ description: '查看/增删改 会话或全局的 必须/禁止 要求',
73
+ input: {
74
+ hint: 'list | add <text> | remove <id> | enable|disable <id> | scope <global|session|project> | clear <scope> | export',
75
+ },
76
+ handler: invocation => handle(ctx, invocation, runtime),
77
+ });
78
+ }, 'baize-rules lifecycle');
79
+ // Host HTTP API for the rules panel (client reads/writes here).
80
+ ctx.effect(() => registerRulesApi(ctx, { globalRulesPath: config.globalRulesPath }), 'baize-rules api');
81
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Package-owned invariant companion for `dsh-baize-rules`.
3
+ *
4
+ * Follows the @deepseek-ai/dsh-invariants contract used by other dsh context
5
+ * plugins (e.g. `@deepseek-ai/dsh-agent-instructions`): a Cordis companion that
6
+ * exports `name` / `inject=['invariants']` / `apply`, and registers itself via
7
+ * `ctx.invariants.register(PACKAGE_NAME, install)`.
8
+ *
9
+ * @module dsh-baize-rules/invariant
10
+ */
11
+ const PACKAGE_NAME = 'dsh-baize-rules';
12
+ /** Cordis companion plugin name. */
13
+ export const name = 'baize-rules-invariant';
14
+ /** Service required before the companion can reserve package ownership. */
15
+ export const inject = ['invariants'];
16
+ /**
17
+ * M1 hook: install a real check that any rules-tagged `user/message` in the
18
+ * session log carries the rules plugin's own source marker and reconstructs from
19
+ * the current rule set. For now it is an explainable no-op so it never blocks a
20
+ * session; the reference implementation's naive `registerInvariant` (a nonexistent
21
+ * export from a wrong package name) is replaced by this contract-correct shape.
22
+ */
23
+ const install = () => { };
24
+ /**
25
+ * Register this package's invariant companion.
26
+ * @param ctx - Cordis context carrying the invariant service.
27
+ * @returns the installed registration's disposer after setup succeeds.
28
+ */
29
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
package/lib/rules.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * User-set rule data model, scope reconciliation, and the model-visible
3
+ * rendering shared by the pre-step injection and the /rules command.
4
+ *
5
+ * @module @deepseek-ai/dsh-rules/rules
6
+ */
7
+ /** Concatenate global then session rules, honoring scope precedence. */
8
+ export function activeRules(view) {
9
+ return [...view.global, ...view.session].filter(rule => rule.enabled);
10
+ }
11
+ /** Escape literal closing tags so user text cannot close the plugin frame. */
12
+ export function escapeReminder(text) {
13
+ return text.replace(/<\/(?:system-reminder)\s*>/gi, '<\\/system-reminder>');
14
+ }
15
+ /** Enforce the configured byte budget by keeping the *prefix* (up to `budgetBytes`
16
+ * UTF-8 bytes) and dropping the tail. Precedence is thus decided by callers:
17
+ * {@link renderRules} lays the most specific section first, so a tight budget
18
+ * sheds the broadest (global) rules before any specific (session/project) rule. */
19
+ export function enforceBudget(lines, budgetBytes) {
20
+ if (budgetBytes <= 0)
21
+ return { lines: [], omitted: lines.length };
22
+ let bytes = 0;
23
+ const kept = [];
24
+ for (const line of lines) {
25
+ const next = bytes + Buffer.byteLength(line, 'utf8');
26
+ if (next > budgetBytes) {
27
+ return { lines: kept, omitted: lines.length - kept.length };
28
+ }
29
+ kept.push(line);
30
+ bytes = next;
31
+ }
32
+ return { lines: kept, omitted: 0 };
33
+ }
34
+ /** Section order by precedence, most specific first. Global stays last so a tight
35
+ * budget drops the broadest rules before any specific (session/project) rule. */
36
+ const SCOPE_ORDER = ['project', 'session', 'global'];
37
+ function sectionHeader(scope) {
38
+ switch (scope) {
39
+ case 'project': return 'Project requirements (this directory only):';
40
+ case 'session': return 'Session requirements (this conversation only):';
41
+ default: return 'Global requirements:';
42
+ }
43
+ }
44
+ const RULE_BULLET = /^- /;
45
+ /** Render the full model-visible <system-reminder> text, or undefined when empty.
46
+ * Sections are laid out specific-first (project > session > global) so prefix
47
+ * retention under {@link enforceBudget} keeps the specific rules and sheds the
48
+ * broadest (global) rules first. Returns undefined when no rule bullet survives
49
+ * the budget, so we never inject a boilerplate-only reminder. */
50
+ export function renderRules(view, budgetBytes) {
51
+ const enabled = (rules) => (rules ?? []).filter(rule => rule.enabled);
52
+ const sections = SCOPE_ORDER
53
+ .map(scope => ({
54
+ scope,
55
+ rules: enabled(scope === 'project' ? view.project
56
+ : scope === 'session' ? view.session : view.global),
57
+ }))
58
+ .filter(section => section.rules.length > 0);
59
+ if (sections.length === 0)
60
+ return undefined;
61
+ const bullet = (rule) => `- ${escapeReminder(rule.text)}`;
62
+ const lines = [
63
+ 'The following user requirements apply to every step of this conversation. Obey them.',
64
+ 'More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.',
65
+ ];
66
+ for (const section of sections) {
67
+ lines.push('', sectionHeader(section.scope));
68
+ lines.push(...section.rules.map(bullet));
69
+ }
70
+ const { lines: kept } = enforceBudget(lines, budgetBytes);
71
+ if (!kept.some(line => RULE_BULLET.test(line)))
72
+ return undefined;
73
+ return `<system-reminder>\n${kept.join('\n')}\n</system-reminder>`;
74
+ }
75
+ /** SHA-1 digest of the rendered text, used to suppress redundant injection. */
76
+ export async function renderDigest(view, budgetBytes) {
77
+ const text = renderRules(view, budgetBytes);
78
+ if (text === undefined)
79
+ return undefined;
80
+ const bytes = new TextEncoder().encode(text);
81
+ const hash = await crypto.subtle.digest('SHA-1', bytes);
82
+ return [...new Uint8Array(hash)].map(byte => byte.toString(16).padStart(2, '0')).join('');
83
+ }
package/lib/store.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Durable rule store: `global` and `session` rules both persist to JSON files
3
+ * under DSH_HOME — `global` to `$DSH_HOME/rules/global.json`, and each session's
4
+ * rules to `$DSH_HOME/rules/sessions/<sessionId>.json`. Files are read lazily at
5
+ * every view/pre-step and written on every mutating `/rules` command, so both
6
+ * scopes survive process restarts. `ctx.fs.writeText` creates parent dirs.
7
+ *
8
+ * Note: an event-sourced `rules/set` session event (方案 A) would be the cleanest
9
+ * "model-visible ⟺ logged" guarantee, but the public `Session.append` cannot mark
10
+ * an out-of-repo event type `ignorable`, so a harness reading such a log would
11
+ * refuse to reconstruct it. The per-session file is the pragmatic durable path.
12
+ *
13
+ * Pure CRUD helpers (addRule/removeRule/mutate/newRule) live in `core.ts`.
14
+ *
15
+ * @module dsh-baize-rules/store
16
+ */
17
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
18
+ /** Resolve the global rules path (config override, else `$DSH_HOME/rules/global.json`). */
19
+ function globalRulesPath(ctx, config) {
20
+ return config.globalRulesPath ?? dshHomePath('rules', 'global.json');
21
+ }
22
+ /** Resolve one session's rules file: `$DSH_HOME/rules/sessions/<sessionId>.json`. */
23
+ function sessionRulesPath(sessionId) {
24
+ return dshHomePath('rules', 'sessions', `${String(sessionId)}.json`);
25
+ }
26
+ /** Slug a project (cwd) into a safe filename for `$DSH_HOME/rules/projects/<slug>.json`. */
27
+ function projectSlug(projectId) {
28
+ return String(projectId).replace(/[\\/:*?"<>|]/g, '_').replace(/^_+|_+$/g, '') || '_';
29
+ }
30
+ /** Resolve one project's rules file (project = session cwd). */
31
+ function projectRulesPath(projectId) {
32
+ return dshHomePath('rules', 'projects', `${projectSlug(projectId)}.json`);
33
+ }
34
+ /** Read a project's rule set from its durable file (empty when missing). */
35
+ export async function readProject(ctx, projectId) {
36
+ if (ctx.fs === undefined || projectId === undefined || String(projectId).length === 0)
37
+ return [];
38
+ const path = projectRulesPath(projectId);
39
+ const target = await ctx.fs.resolve(path);
40
+ const info = await ctx.fs.stat(target);
41
+ if (info === undefined)
42
+ return [];
43
+ const raw = await ctx.fs.readText(target);
44
+ const parsed = JSON.parse(raw);
45
+ if (!Array.isArray(parsed))
46
+ throw new Error(`rules: project rules file is not a JSON array: ${path}`);
47
+ return sanitizeRules(parsed);
48
+ }
49
+ /** Replace a project's rule set in its durable file. */
50
+ export async function writeProject(ctx, projectId, rules) {
51
+ if (ctx.fs === undefined || projectId === undefined || String(projectId).length === 0)
52
+ return;
53
+ const path = projectRulesPath(projectId);
54
+ const target = await ctx.fs.resolve(path);
55
+ await ctx.fs.writeText(target, `${JSON.stringify(rules, null, 2)}\n`);
56
+ }
57
+ /** Validate a parsed array into well-formed rules; drop the malformed entries loudly.
58
+ * Rules are plain text — there is no `kind` field anymore, but legacy stored
59
+ * entries that carried `kind` are accepted (the field is ignored on rewrite). */
60
+ function sanitizeRules(parsed) {
61
+ return parsed.flatMap((item) => {
62
+ if (typeof item !== 'object' || item === null)
63
+ throw new Error('rules: entry is not an object');
64
+ const rule = item;
65
+ const { text, id } = rule;
66
+ if (typeof text !== 'string' || typeof id !== 'string') {
67
+ throw new Error('rules: entry must carry string id and text');
68
+ }
69
+ return [{
70
+ id,
71
+ text,
72
+ enabled: rule.enabled !== false,
73
+ createdAt: typeof rule.createdAt === 'number' ? rule.createdAt : 0,
74
+ updatedAt: typeof rule.updatedAt === 'number' ? rule.updatedAt : 0,
75
+ }];
76
+ });
77
+ }
78
+ /** Read the global rule set, tolerating a missing file and failing loud on a malformed store. */
79
+ export async function readGlobal(ctx, config) {
80
+ if (ctx.fs === undefined)
81
+ return [];
82
+ const path = globalRulesPath(ctx, config);
83
+ const target = await ctx.fs.resolve(path);
84
+ const info = await ctx.fs.stat(target);
85
+ if (info === undefined)
86
+ return [];
87
+ const raw = await ctx.fs.readText(target);
88
+ const parsed = JSON.parse(raw);
89
+ if (!Array.isArray(parsed))
90
+ throw new Error(`rules: global rules file is not a JSON array: ${path}`);
91
+ return sanitizeRules(parsed);
92
+ }
93
+ /** Write the global rule set. */
94
+ export async function writeGlobal(ctx, config, rules) {
95
+ if (ctx.fs === undefined)
96
+ return;
97
+ const path = globalRulesPath(ctx, config);
98
+ const target = await ctx.fs.resolve(path);
99
+ await ctx.fs.writeText(target, `${JSON.stringify(rules, null, 2)}\n`);
100
+ }
101
+ /** Read the per-session rule set from its durable file (empty when missing). */
102
+ export async function readSession(ctx, sessionId) {
103
+ if (ctx.fs === undefined)
104
+ return [];
105
+ const path = sessionRulesPath(sessionId);
106
+ const target = await ctx.fs.resolve(path);
107
+ const info = await ctx.fs.stat(target);
108
+ if (info === undefined)
109
+ return [];
110
+ const raw = await ctx.fs.readText(target);
111
+ const parsed = JSON.parse(raw);
112
+ if (!Array.isArray(parsed))
113
+ throw new Error(`rules: session rules file is not a JSON array: ${path}`);
114
+ return sanitizeRules(parsed);
115
+ }
116
+ /** Replace the per-session rule set in its durable file. */
117
+ export async function writeSession(ctx, sessionId, rules) {
118
+ if (ctx.fs === undefined)
119
+ return;
120
+ const path = sessionRulesPath(sessionId);
121
+ const target = await ctx.fs.resolve(path);
122
+ await ctx.fs.writeText(target, `${JSON.stringify(rules, null, 2)}\n`);
123
+ }
124
+ /** Assemble the merged view used for injection and /rules list. */
125
+ export async function view(ctx, agent, config) {
126
+ return {
127
+ global: await readGlobal(ctx, config),
128
+ session: await readSession(ctx, agent.id),
129
+ };
130
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Host HTTP API for the rules panel. The client (`lib/client.js`) fetches rules
3
+ * via GET and applies mutations via POST, reusing the same store + core as the
4
+ * `/baize-rules` command (so the panel and command are the same source of truth).
5
+ *
6
+ * Routes (json):
7
+ * GET /baize-rules.api?sessionId=... -> { global, session }
8
+ * POST /baize-rules.api { sessionId, raw, scope } -> { ok, text, view }
9
+ *
10
+ * @module dsh-baize-rules/api
11
+ */
12
+ import type { Context } from '@deepseek-ai/cordis';
13
+ type RulesApiOptions = {
14
+ globalRulesPath?: string;
15
+ };
16
+ /** Register the rules panel API on the host web server. */
17
+ export declare function registerRulesApi(ctx: Context, options?: RulesApiOptions): () => void;
18
+ export {};
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Thin dsh adapter for the `/rules` command: feeds the live `view` + current
3
+ * `defaultScope` into the dependency-free decision core (`core.ts`), then
4
+ * persists the resulting `nextView` (global → disk, session → memory) and any
5
+ * `/rules scope` default change.
6
+ *
7
+ * @module dsh-baize-rules/command
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis';
10
+ import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands';
11
+ import type { RuleScope } from './rules.ts';
12
+ /** Runtime state the command handler needs from the plugin's `apply`. */
13
+ export interface RulesRuntime {
14
+ readonly globalRulesPath?: string;
15
+ readonly getScope: () => RuleScope;
16
+ readonly setScope: (scope: RuleScope) => void;
17
+ }
18
+ /** The registered handler contract: adapt command input → core decision → persist. */
19
+ export declare function handle(ctx: Context, invocation: CommandInvocation, runtime: RulesRuntime): Promise<CommandResult>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Dependency-free rules-command core: parsing, scope resolution, and CRUD
3
+ * application over a {@link RuleView}. No dsh runtime needed, so it is testable
4
+ * in the Loop 0 harness (`pnpm test`). The dsh-facing adapter (`command.ts`)
5
+ * feeds in the live `view` + `defaultScope` and persists `nextView`/`defaultScope`.
6
+ *
7
+ * @module dsh-baize-rules/core
8
+ */
9
+ import type { Rule, RuleScope, RuleView } from './rules.ts';
10
+ export declare const RULE_SCOPES: readonly RuleScope[];
11
+ export type CommandVerb = 'list' | 'add' | 'remove' | 'enable' | 'disable' | 'edit' | 'scope' | 'clear' | 'export';
12
+ export interface ParsedCommand {
13
+ readonly verb: CommandVerb | 'help' | undefined;
14
+ readonly args: readonly string[];
15
+ /** Explicit scope keyword (leading or trailing), if present. */
16
+ readonly scope?: RuleScope;
17
+ }
18
+ export interface CommandInput {
19
+ readonly raw: string;
20
+ readonly view: RuleView;
21
+ readonly defaultScope: RuleScope;
22
+ }
23
+ export interface CommandOutput {
24
+ readonly ok: boolean;
25
+ readonly text: string;
26
+ /** Present when the store should be persisted to this new view. */
27
+ readonly nextView?: RuleView;
28
+ /** Present when `/rules scope` chose a new default scope. */
29
+ readonly defaultScope?: RuleScope;
30
+ }
31
+ /** Parse a `/rules` line into a verb + args, isolating an explicit scope keyword.
32
+ * Scope is accepted as a **leading** token (`/rules global add …`) or a
33
+ * **trailing** token (`/rules add … global`, only the LAST arg qualifies),
34
+ * so a rule whose text merely contains "global" is never misread as a scope. */
35
+ export declare function parseCommand(raw: string): ParsedCommand;
36
+ /** Explicit scope wins, else the caller's default. */
37
+ export declare function resolveScope(scope: RuleScope | undefined, fallback: RuleScope): RuleScope;
38
+ /** Mint a fully-specified rule (dependency-free; uses global `crypto.randomUUID`). */
39
+ export declare function newRule(text: string, now?: number): Rule;
40
+ export declare function addRule(view: RuleView, scope: RuleScope, rule: Rule): RuleView;
41
+ /** Return a new view with a rule removed from one scope. */
42
+ export declare function removeRule(view: RuleView, scope: RuleScope, id: string): RuleView;
43
+ /** Mutate a copy of a scope's rules for the given id; returns whether found. */
44
+ export declare function mutate(view: RuleView, scope: RuleScope, id: string, fn: (rule: Rule) => Rule): RuleView;
45
+ /** Render a compact human-readable list of the active rules. */
46
+ export declare function formatList(view: RuleView): string;
47
+ /** Apply one parsed `/rules` command to the given view + default scope. */
48
+ export declare function runCommand(input: CommandInput): CommandOutput;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * User-set session/global must-do and must-not requirements injected at
3
+ * conversation start. Named for 白泽 (Baize), the beast that knows all and
4
+ * distinguishes right from wrong — hence the must/mustNot framing.
5
+ *
6
+ * @module dsh-baize-rules
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ import z from '@deepseek-ai/schemastery';
10
+ /** Cordis plugin name used by loader diagnostics + the injected message source. */
11
+ export declare const name = "baize-rules";
12
+ /** Services granted on `ctx` by cordis before `apply` runs. We use `ctx.agents`
13
+ * (agent-plane scoping), `ctx.commands` (register /baize-rules), `ctx.fs`
14
+ * (persist global + session rule files), and `ctx.webServer` (rules panel API);
15
+ * each must be declared in `inject`. */
16
+ export declare const inject: string[];
17
+ /** Policy for the rules plugin. Invalid values fail plugin load. */
18
+ export interface Config {
19
+ /** Default scope that `/rules add|remove|...` edits when the line omits a scope keyword. */
20
+ scope: 'global' | 'session';
21
+ /** Model-visible byte budget; a tight budget sheds the broadest rules first. */
22
+ maxBytes: number;
23
+ /** Override the global rules file path (default `$DSH_HOME/rules/global.json`). */
24
+ globalRulesPath?: string;
25
+ /** When true, re-render an updated rules message on every step, not just on change. */
26
+ injectAtEveryStep?: boolean;
27
+ }
28
+ /** Schemastery validation for {@link Config}. */
29
+ export declare const Config: z<Config>;
30
+ /**
31
+ * Register a prepended pre-step listener that injects the rendered rules as a
32
+ * durable user message on conversation start, plus the `/rules` command.
33
+ * @param ctx - plugin context; listener and command dispose with it.
34
+ * @param config - scope, budget, and redundancy policy.
35
+ */
36
+ export declare function apply(ctx: Context, config: Config): void;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Package-owned invariant companion for `dsh-baize-rules`.
3
+ *
4
+ * Follows the @deepseek-ai/dsh-invariants contract used by other dsh context
5
+ * plugins (e.g. `@deepseek-ai/dsh-agent-instructions`): a Cordis companion that
6
+ * exports `name` / `inject=['invariants']` / `apply`, and registers itself via
7
+ * `ctx.invariants.register(PACKAGE_NAME, install)`.
8
+ *
9
+ * @module dsh-baize-rules/invariant
10
+ */
11
+ import type { Context } from '@deepseek-ai/cordis';
12
+ /** Cordis companion plugin name. */
13
+ export declare const name = "baize-rules-invariant";
14
+ /** Service required before the companion can reserve package ownership. */
15
+ export declare const inject: string[];
16
+ /**
17
+ * Register this package's invariant companion.
18
+ * @param ctx - Cordis context carrying the invariant service.
19
+ * @returns the installed registration's disposer after setup succeeds.
20
+ */
21
+ export declare const apply: (ctx: Context) => Promise<() => void>;