@danypops/papyrus 0.45.0 → 0.45.2
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/package.json +1 -1
- package/src/cli/rules-command.ts +6 -2
- package/src/client.ts +26 -5
- package/src/domain-services.ts +20 -1
- package/src/handlers/rules.ts +14 -7
- package/src/modules/rules.ts +57 -26
package/package.json
CHANGED
package/src/cli/rules-command.ts
CHANGED
|
@@ -124,8 +124,12 @@ const showCommand = buildCommand({
|
|
|
124
124
|
|
|
125
125
|
const previewCommand = buildCommand({
|
|
126
126
|
func: async function (this: RulesContext, _flags: Record<string, never>, id: string) {
|
|
127
|
-
const
|
|
128
|
-
|
|
127
|
+
const result = await this.client.call<Record<string, unknown>, { preview: string; combinedLength: number; warning?: string }>(
|
|
128
|
+
"rules.preview",
|
|
129
|
+
{ id },
|
|
130
|
+
);
|
|
131
|
+
const text = result.warning === undefined ? result.preview : `${result.preview}\n\n⚠ ${result.warning}`;
|
|
132
|
+
render.call(this, result, text);
|
|
129
133
|
},
|
|
130
134
|
parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Rule id", parse: String, placeholder: "id" }] } },
|
|
131
135
|
docs: { brief: "Render a Rule's own condition/action/body preview text" },
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { spawn as spawnProcess } from "node:child_process";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
connectWithVersionCheck,
|
|
5
|
+
type ExpectedVersion,
|
|
6
|
+
type SpawnPlatformOptions,
|
|
7
|
+
spawnDetachedDaemon,
|
|
8
|
+
} from "@danypops/vehicle-client/daemon-client";
|
|
4
9
|
import { createLiveVersionExpectation } from "@danypops/vehicle-client/version";
|
|
5
10
|
import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_DIR_ENV, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
|
|
6
11
|
import { type DaemonHandle, daemonStateDir, readDaemonHandle } from "./daemon/daemon-state.ts";
|
|
@@ -74,6 +79,25 @@ function papyrusCliPath(): string {
|
|
|
74
79
|
return fileURLToPath(new URL("cli.ts", import.meta.url));
|
|
75
80
|
}
|
|
76
81
|
|
|
82
|
+
/**
|
|
83
|
+
* spawnDetachedDaemon's injected spawn() callback, factored out for a direct unit test.
|
|
84
|
+
*
|
|
85
|
+
* A spawn() failure (missing binPath, no exec permission, wrong interpreter) surfaces
|
|
86
|
+
* asynchronously as an "error" event on the ChildProcess -- with no listener, Node treats it
|
|
87
|
+
* as an uncaught exception and kills the whole host process, not just this one connect
|
|
88
|
+
* attempt (a real incident: auto-spawning against a since-deleted binPath crashed Pi itself).
|
|
89
|
+
* The listener below turns that into an ordinary logged failure instead: the handle file
|
|
90
|
+
* simply never appears, and connectWithPolicy's own poll-then-timeout already reports that
|
|
91
|
+
* as its usual, catchable fallbackMessage error.
|
|
92
|
+
*/
|
|
93
|
+
export function spawnPapyrusDaemonProcess(command: string, args: string[], spawnOptions: SpawnPlatformOptions): void {
|
|
94
|
+
const child = spawnProcess(command, args, spawnOptions);
|
|
95
|
+
child.on("error", (error) => {
|
|
96
|
+
console.error(`Papyrus daemon auto-spawn failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
+
});
|
|
98
|
+
child.unref();
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
/** connectWithVersionCheck's killStaleProcess callback, factored out for a direct unit test -- a real spawned daemon can't be made to report a mismatched version without a second build. */
|
|
78
102
|
export function killStalePapyrusDaemon(handle: Pick<DaemonHandle, "pid">): void {
|
|
79
103
|
if (handle.pid <= 0) return; // daemon-state.ts's inert "unknown pid" sentinel -- never a real process.
|
|
@@ -123,10 +147,7 @@ export async function connectPapyrusClient(
|
|
|
123
147
|
binPath: papyrusCliPath(),
|
|
124
148
|
args: ["serve"],
|
|
125
149
|
env: { ...(options.env ?? process.env), [DAEMON_DIR_ENV]: dir },
|
|
126
|
-
spawn:
|
|
127
|
-
const child = spawnProcess(command, args, spawnOptions);
|
|
128
|
-
child.unref();
|
|
129
|
-
},
|
|
150
|
+
spawn: spawnPapyrusDaemonProcess,
|
|
130
151
|
});
|
|
131
152
|
},
|
|
132
153
|
fallbackMessage: "Papyrus daemon failed to start automatically; run `papyrus service install` or `papyrus serve` manually.",
|
package/src/domain-services.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS,
|
|
17
17
|
PLAYBOOK_MAX_STEPS,
|
|
18
18
|
RULE_TEXT_HARD_LIMIT_CHARACTERS,
|
|
19
|
+
RULE_TEXT_SOFT_TARGET_CHARACTERS,
|
|
19
20
|
SKILL_MAX_ENUM_VALUES,
|
|
20
21
|
} from "./constants.ts";
|
|
21
22
|
import {
|
|
@@ -312,8 +313,26 @@ export type RuleTransition = "enable" | "disable";
|
|
|
312
313
|
* reviewed, and a warning nobody reads is not a bound. See RULE_TEXT_HARD_LIMIT_CHARACTERS's
|
|
313
314
|
* own comment in constants.ts for the research this threshold is grounded in.
|
|
314
315
|
*/
|
|
316
|
+
export function ruleCombinedLength(condition: string | undefined, action: string | undefined, body: string | undefined): number {
|
|
317
|
+
return (condition ?? "").length + (action ?? "").length + (body ?? "").length;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Non-blocking counterpart to assertRuleTextWithinBounds's hard rejection: the same combined
|
|
322
|
+
* length, informational once it crosses the soft target, so a caller doesn't have to self-police
|
|
323
|
+
* with a manual character count before every rules.create/update. Returns undefined at or under
|
|
324
|
+
* the target -- the common case, not worth a field only ever seen as "undefined" on the wire.
|
|
325
|
+
*/
|
|
326
|
+
export function ruleCombinedLengthWarning(combinedLength: number): string | undefined {
|
|
327
|
+
if (combinedLength <= RULE_TEXT_SOFT_TARGET_CHARACTERS) return undefined;
|
|
328
|
+
return (
|
|
329
|
+
`condition+action+body is ${combinedLength} characters, over the ${RULE_TEXT_SOFT_TARGET_CHARACTERS}-character soft target ` +
|
|
330
|
+
`(hard limit ${RULE_TEXT_HARD_LIMIT_CHARACTERS}) -- consider moving detail into a linked Doc.`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
315
334
|
function assertRuleTextWithinBounds(condition: string | undefined, action: string | undefined, body: string | undefined): void {
|
|
316
|
-
const combined = (condition
|
|
335
|
+
const combined = ruleCombinedLength(condition, action, body);
|
|
317
336
|
if (combined > RULE_TEXT_HARD_LIMIT_CHARACTERS) {
|
|
318
337
|
throw new Error(
|
|
319
338
|
`rule condition+action+body is ${combined} characters, exceeding the ${RULE_TEXT_HARD_LIMIT_CHARACTERS}-character bound. ` +
|
package/src/handlers/rules.ts
CHANGED
|
@@ -49,7 +49,7 @@ export function registerRulesVehicleOperations(registry: VehicleRegistry, artifa
|
|
|
49
49
|
|
|
50
50
|
define(
|
|
51
51
|
"create",
|
|
52
|
-
"Creates a Rule -- a standing constraint injected into the agent system prompt while active. project_root is optional (omitted = unscoped).",
|
|
52
|
+
"Creates a Rule -- a standing constraint injected into the agent system prompt while active. project_root is optional (omitted = unscoped). The response includes combinedLength (condition+action+body character count) and a non-blocking warning once it exceeds the ~600-character soft target (hard-rejected past 4000).",
|
|
53
53
|
"local-write",
|
|
54
54
|
{
|
|
55
55
|
title: stringProp,
|
|
@@ -77,14 +77,21 @@ export function registerRulesVehicleOperations(registry: VehicleRegistry, artifa
|
|
|
77
77
|
(input) => input,
|
|
78
78
|
);
|
|
79
79
|
|
|
80
|
-
define(
|
|
81
|
-
|
|
82
|
-
id
|
|
83
|
-
|
|
80
|
+
define(
|
|
81
|
+
"show",
|
|
82
|
+
"Shows one Rule by id or title. The response includes combinedLength (condition+action+body character count) and a non-blocking warning once it exceeds the ~600-character soft target.",
|
|
83
|
+
"read",
|
|
84
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
85
|
+
[],
|
|
86
|
+
(input) => ({
|
|
87
|
+
...input,
|
|
88
|
+
id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name),
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
84
91
|
|
|
85
92
|
define(
|
|
86
93
|
"preview",
|
|
87
|
-
"Renders a Rule's own condition/action/body preview text with no side effects.",
|
|
94
|
+
"Renders a Rule's own condition/action/body preview text with no side effects. Response: { preview, combinedLength, warning? } -- warning is present only once combinedLength exceeds the ~600-character soft target.",
|
|
88
95
|
"read",
|
|
89
96
|
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
90
97
|
[],
|
|
@@ -146,7 +153,7 @@ export function registerRulesVehicleOperations(registry: VehicleRegistry, artifa
|
|
|
146
153
|
|
|
147
154
|
define(
|
|
148
155
|
"update",
|
|
149
|
-
"Changes a Rule's title/body/labels (at least one required). Body updates still enforce the same combined condition+action+body context-tax bound as creation.",
|
|
156
|
+
"Changes a Rule's title/body/labels (at least one required). Body updates still enforce the same combined condition+action+body context-tax bound as creation. The response includes combinedLength and a non-blocking warning once it exceeds the ~600-character soft target.",
|
|
150
157
|
"local-write",
|
|
151
158
|
{
|
|
152
159
|
id: stringProp,
|
package/src/modules/rules.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* registry" convention.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { summarizeArtifact } from "../artifact/artifact.ts";
|
|
14
|
+
import { type Artifact, summarizeArtifact } from "../artifact/artifact.ts";
|
|
15
15
|
import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
16
16
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
17
17
|
import {
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
gateTaskWithRule,
|
|
21
21
|
listRules,
|
|
22
22
|
previewRule,
|
|
23
|
+
ruleCombinedLength,
|
|
24
|
+
ruleCombinedLengthWarning,
|
|
23
25
|
showRule,
|
|
24
26
|
transitionRule,
|
|
25
27
|
updateRule,
|
|
@@ -42,6 +44,22 @@ const artifactFilter = (input: OperationInput) => ({
|
|
|
42
44
|
projectRoot: optionalString(input, "project_root"),
|
|
43
45
|
});
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Adds a rule's own combinedLength (and, past the soft target, a non-blocking warning) to any
|
|
49
|
+
* response shaped as its own Artifact -- additive fields alongside every existing one, so a
|
|
50
|
+
* caller reading .id/.title/... unchanged still works. Removes the need for a manual
|
|
51
|
+
* len(condition)+len(action)+len(body) count before every rules.create/update call.
|
|
52
|
+
*/
|
|
53
|
+
function withRuleLengthInfo(rule: Artifact): Artifact & { combinedLength: number; warning?: string } {
|
|
54
|
+
const combinedLength = ruleCombinedLength(
|
|
55
|
+
typeof rule.extra.condition === "string" ? rule.extra.condition : undefined,
|
|
56
|
+
typeof rule.extra.action === "string" ? rule.extra.action : undefined,
|
|
57
|
+
rule.body,
|
|
58
|
+
);
|
|
59
|
+
const warning = ruleCombinedLengthWarning(combinedLength);
|
|
60
|
+
return { ...rule, combinedLength, ...(warning === undefined ? {} : { warning }) };
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
/** Registers every rules.* operation except rules.injectable (see module comment). Behavior is unchanged from the prior inline handlers in src/service.ts. */
|
|
46
64
|
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. rules.injectable is deliberately absent -- see the module comment above. */
|
|
47
65
|
export const RULES_OPERATION_NAMES = [
|
|
@@ -64,28 +82,39 @@ export function rulesOperations(artifacts: ArtifactStore, scopes: ArtifactScopeS
|
|
|
64
82
|
});
|
|
65
83
|
return [
|
|
66
84
|
define("rules.create", (input: OperationInput) =>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
withRuleLengthInfo(
|
|
86
|
+
createRule(
|
|
87
|
+
artifacts,
|
|
88
|
+
scopes,
|
|
89
|
+
{
|
|
90
|
+
title: string(input, "title"),
|
|
91
|
+
body: optionalString(input, "body"),
|
|
92
|
+
condition: optionalString(input, "condition"),
|
|
93
|
+
action: optionalString(input, "rule_action") ?? optionalString(input, "governance_action"),
|
|
94
|
+
severity: optionalString(input, "severity") as "block" | "warn" | "info" | undefined,
|
|
95
|
+
labels: input.labels as string[] | undefined,
|
|
96
|
+
extra: input.extra as Record<string, unknown> | undefined,
|
|
97
|
+
projectRoot: optionalString(input, "project_root"),
|
|
98
|
+
},
|
|
99
|
+
eventContext(input),
|
|
100
|
+
),
|
|
81
101
|
),
|
|
82
102
|
),
|
|
83
103
|
define("rules.list", (input: OperationInput) => {
|
|
84
104
|
const rules = listRules(artifacts, scopes, artifactFilter(input));
|
|
85
105
|
return optionalBoolean(input, "full") === true ? rules : rules.map(summarizeArtifact);
|
|
86
106
|
}),
|
|
87
|
-
define("rules.show", (input: OperationInput) => showRule(artifacts, string(input, "id"))),
|
|
88
|
-
define("rules.preview", (input: OperationInput) =>
|
|
107
|
+
define("rules.show", (input: OperationInput) => withRuleLengthInfo(showRule(artifacts, string(input, "id")))),
|
|
108
|
+
define("rules.preview", (input: OperationInput) => {
|
|
109
|
+
const rule = showRule(artifacts, string(input, "id"));
|
|
110
|
+
const combinedLength = ruleCombinedLength(
|
|
111
|
+
typeof rule.extra.condition === "string" ? rule.extra.condition : undefined,
|
|
112
|
+
typeof rule.extra.action === "string" ? rule.extra.action : undefined,
|
|
113
|
+
rule.body,
|
|
114
|
+
);
|
|
115
|
+
const warning = ruleCombinedLengthWarning(combinedLength);
|
|
116
|
+
return { preview: previewRule(artifacts, string(input, "id")), combinedLength, ...(warning === undefined ? {} : { warning }) };
|
|
117
|
+
}),
|
|
89
118
|
define("rules.enable", (input: OperationInput) => transitionRule(artifacts, string(input, "id"), "enable", eventContext(input))),
|
|
90
119
|
define("rules.disable", (input: OperationInput) => transitionRule(artifacts, string(input, "id"), "disable", eventContext(input))),
|
|
91
120
|
define("rules.gate", (input: OperationInput) =>
|
|
@@ -95,15 +124,17 @@ export function rulesOperations(artifacts: ArtifactStore, scopes: ArtifactScopeS
|
|
|
95
124
|
assignRuleProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root")),
|
|
96
125
|
),
|
|
97
126
|
define("rules.update", (input: OperationInput) =>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
127
|
+
withRuleLengthInfo(
|
|
128
|
+
updateRule(
|
|
129
|
+
artifacts,
|
|
130
|
+
string(input, "id"),
|
|
131
|
+
{
|
|
132
|
+
title: optionalString(input, "title"),
|
|
133
|
+
body: optionalString(input, "body"),
|
|
134
|
+
labels: input.labels as string[] | undefined,
|
|
135
|
+
},
|
|
136
|
+
eventContext(input),
|
|
137
|
+
),
|
|
107
138
|
),
|
|
108
139
|
),
|
|
109
140
|
];
|