portable-agent-layer 0.108.0 → 0.109.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 +2 -1
- package/package.json +6 -2
- package/src/cli/correction.ts +115 -0
- package/src/cli/rule.ts +121 -14
- package/src/cli/tree.ts +2 -0
- package/src/hooks/handlers/rating.ts +21 -2
- package/src/hooks/handlers/update-check.ts +6 -1
- package/src/hooks/lib/adaptation-candidates.ts +41 -13
- package/src/hooks/lib/adaptation-rules.ts +138 -9
- package/src/hooks/lib/adaptation-steering.ts +7 -2
- package/src/hooks/lib/adaptation-turns.ts +43 -1
- package/src/hooks/lib/context.ts +2 -2
- package/src/hooks/lib/correction-backfill.ts +56 -0
- package/src/hooks/lib/correction-kind.ts +165 -0
- package/src/hooks/lib/correction-rates.ts +151 -0
- package/src/hooks/lib/models.ts +7 -3
- package/src/hooks/lib/opinion-gate.ts +73 -0
- package/src/hooks/lib/rule-drafter.ts +57 -24
- package/src/hooks/lib/semi-static.ts +2 -2
- package/src/tools/control-room/relationship.ts +43 -18
- package/src/tools/control-room/server.ts +12 -5
- package/src/tools/control-room/ui/dist/assets/{index-mFpXpiBj.js → index-D0pEd7Ln.js} +1 -1
- package/src/tools/control-room/ui/dist/index.html +1 -1
- package/src/tools/control-room/ui/lib/write.ts +3 -1
- package/src/tools/control-room/ui/screens/relationship.tsx +101 -29
package/src/hooks/lib/models.ts
CHANGED
|
@@ -56,12 +56,16 @@ export function isFixedModelRoute(route: string): route is FixedModelRoute {
|
|
|
56
56
|
return Object.hasOwn(INFERENCE_MODELS, route);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Callers on Haiku 5.5: those whose prompt passed its eval there, and every new
|
|
61
|
+
* caller, whose eval is iterated on 5.5 until it passes. The rest stay on
|
|
62
|
+
* HAIKU_MODEL until theirs is evaluated.
|
|
63
|
+
*/
|
|
64
|
+
const ANTHROPIC_ON_HAIKU_5_5 = new Set(["rule-drafter", "correction-kind"]);
|
|
61
65
|
|
|
62
66
|
function evaluatedModel(models: RouteModels, caller?: string): string | undefined {
|
|
63
67
|
const onAnthropic = models === ANTHROPIC_MODELS;
|
|
64
|
-
return onAnthropic && caller &&
|
|
68
|
+
return onAnthropic && caller && ANTHROPIC_ON_HAIKU_5_5.has(caller)
|
|
65
69
|
? HAIKU_5_5_MODEL
|
|
66
70
|
: undefined;
|
|
67
71
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a draft may be kept as an opinion. Opinions steer every session, so
|
|
3
|
+
* one is offered only once its kind has recurred enough to be measured, and
|
|
4
|
+
* only while no opinion or undecided draft already targets that kind: a second
|
|
5
|
+
* one would share its rate.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type AdaptationRule, isOpinion, type OpinionGate } from "./adaptation-rules";
|
|
9
|
+
import type { Turn } from "./adaptation-turns";
|
|
10
|
+
import { hasCurrentKind } from "./correction-kind";
|
|
11
|
+
import { type OpinionEffect, opinionEffectOf } from "./correction-rates";
|
|
12
|
+
|
|
13
|
+
const MIN_KIND_CORRECTIONS = 5;
|
|
14
|
+
|
|
15
|
+
function correctionsOfKind(kind: string, turns: Turn[]): number {
|
|
16
|
+
return turns.filter(
|
|
17
|
+
(turn) =>
|
|
18
|
+
turn.confirmed === true && hasCurrentKind(turn) && turn.correctionKind.kind === kind
|
|
19
|
+
).length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function offersOpinion(rule: AdaptationRule): boolean {
|
|
23
|
+
return rule.status === "draft" && !rule.widens && !rule.opinionGate;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sameKind(kind: string, rules: AdaptationRule[]): AdaptationRule | undefined {
|
|
27
|
+
return rules.find((r) => r.kind === kind && (isOpinion(r) || offersOpinion(r)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function opinionGate(
|
|
31
|
+
kind: string,
|
|
32
|
+
rules: AdaptationRule[],
|
|
33
|
+
turns: Turn[]
|
|
34
|
+
): OpinionGate | undefined {
|
|
35
|
+
const taken = sameKind(kind, rules);
|
|
36
|
+
if (taken) return { duplicates: taken.id };
|
|
37
|
+
const corrections = correctionsOfKind(kind, turns);
|
|
38
|
+
return corrections < MIN_KIND_CORRECTIONS ? { corrections } : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function opinionAdvice(id: string, effect: OpinionEffect | null): string {
|
|
42
|
+
if (effect?.verdict === "lower")
|
|
43
|
+
return `opinion ${id} lowers its kind, so this is a variant; promote ${id} to a lasting principle to free its slot`;
|
|
44
|
+
if (effect?.verdict === "not-lower")
|
|
45
|
+
return `opinion ${id} is not lowering its kind; reword ${id} instead of adding another`;
|
|
46
|
+
return `opinion ${id}'s effect is not measurable yet (${effect?.verdict ?? "no effect"}); held beside it`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function duplicateAdvice(
|
|
50
|
+
id: string,
|
|
51
|
+
rules: AdaptationRule[],
|
|
52
|
+
turns: Turn[],
|
|
53
|
+
now: Date
|
|
54
|
+
): string {
|
|
55
|
+
const target = rules.find((r) => r.id === id);
|
|
56
|
+
if (target && isOpinion(target))
|
|
57
|
+
return opinionAdvice(id, opinionEffectOf(target, rules, turns, now));
|
|
58
|
+
if (target?.status === "draft")
|
|
59
|
+
return `draft ${id} already offers an opinion for this kind; decide it first`;
|
|
60
|
+
return `${id} targeted this kind and is no longer active`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function gateAdvice(
|
|
64
|
+
rule: AdaptationRule,
|
|
65
|
+
rules: AdaptationRule[],
|
|
66
|
+
turns: Turn[],
|
|
67
|
+
now: Date = new Date()
|
|
68
|
+
): string | undefined {
|
|
69
|
+
const gate = rule.status === "draft" ? rule.opinionGate : undefined;
|
|
70
|
+
if (!gate) return undefined;
|
|
71
|
+
if ("duplicates" in gate) return duplicateAdvice(gate.duplicates, rules, turns, now);
|
|
72
|
+
return `${gate.corrections} corrections of its kind so far; an opinion needs ${MIN_KIND_CORRECTIONS}`;
|
|
73
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rule drafter: after a confirmed correction, the small model decides
|
|
3
|
-
* whether it repeats an earlier one and, if so, drafts
|
|
4
|
-
* prevented them all
|
|
3
|
+
* whether it repeats an earlier one and, if so, drafts the instruction that
|
|
4
|
+
* would have prevented them all: an opinion, or a rule when its optional
|
|
5
|
+
* trigger proves.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import type { CandidateInput } from "./adaptation-candidates";
|
|
8
|
-
import type
|
|
9
|
+
import { isTriggerRule, type RuleTrigger } from "./adaptation-rules";
|
|
9
10
|
import { type RequestedTurn, type Turn, withRequests } from "./adaptation-turns";
|
|
11
|
+
import { hasCurrentKind } from "./correction-kind";
|
|
10
12
|
import { firesOn } from "./rule-proof";
|
|
11
13
|
|
|
12
14
|
const MIN_CITED = 2;
|
|
@@ -18,14 +20,17 @@ type Correction = RequestedTurn;
|
|
|
18
20
|
export interface KnownRule {
|
|
19
21
|
id: string;
|
|
20
22
|
when: string;
|
|
21
|
-
trigger
|
|
23
|
+
trigger?: RuleTrigger;
|
|
22
24
|
steering: string;
|
|
23
25
|
check?: string;
|
|
24
26
|
status: string;
|
|
27
|
+
delivery?: "always";
|
|
25
28
|
widens?: string;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
type TriggeredRule = KnownRule & { trigger: RuleTrigger };
|
|
32
|
+
|
|
33
|
+
const DRAFTER_SYSTEM_PROMPT = `You turn an AI assistant's repeated mistakes into one instruction that prevents them.
|
|
29
34
|
|
|
30
35
|
You get the corrections the user made recently, numbered and oldest first. Each has the end of the assistant's reply the user corrected, the user's correcting message, what the reply got wrong, and, when known, the user's request the reply answered. The newest one just happened. Then the known rules; an approved rule shows its trigger and whether that trigger fires on the newest correction.
|
|
31
36
|
|
|
@@ -37,9 +42,9 @@ Otherwise set widens to an empty string and decide whether the newest correction
|
|
|
37
42
|
|
|
38
43
|
If it is not, or another known rule already covers it, set recurring to false, say why in reason in one sentence, leave the text fields empty and set cites to [].
|
|
39
44
|
|
|
40
|
-
If it is, draft one
|
|
41
|
-
- when: the situation, in one sentence.
|
|
42
|
-
- side and pattern:
|
|
45
|
+
If it is, draft one instruction. It steers every session unless a trigger proves it can fire only when needed:
|
|
46
|
+
- when: the situation, in one sentence, general enough to cover the next time and not tied to these corrections' topics.
|
|
47
|
+
- side and pattern: optional. Leave both empty unless the situation always shows in the same words, in the user's request (side "prompt") or at the end of the assistant's reply (side "reply"). Then give a case-insensitive JavaScript regular expression that matches every cited correction on that side, would match the next one, and does not match ordinary turns. A pattern built from the words of these corrections or of the user's correcting message is worse than none.
|
|
43
48
|
- steering: the instruction to the assistant, imperative, at most two sentences.
|
|
44
49
|
- check: how to tell from a reply whether the instruction was followed, in one sentence.
|
|
45
50
|
- cites: the numbers of the corrections this rule would have prevented, including the newest.
|
|
@@ -52,7 +57,7 @@ const DRAFTER_SCHEMA = {
|
|
|
52
57
|
widens: { type: "string" },
|
|
53
58
|
reason: { type: "string" },
|
|
54
59
|
when: { type: "string" },
|
|
55
|
-
side: { enum: ["prompt", "reply"] },
|
|
60
|
+
side: { enum: ["prompt", "reply", ""] },
|
|
56
61
|
pattern: { type: "string" },
|
|
57
62
|
steering: { type: "string" },
|
|
58
63
|
check: { type: "string" },
|
|
@@ -80,12 +85,13 @@ export function canRepeat(corrections: Correction[]): boolean {
|
|
|
80
85
|
return corrections.length >= MIN_CITED;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
function missedApprovedRules(
|
|
88
|
+
function missedApprovedRules(
|
|
89
|
+
corrections: Correction[],
|
|
90
|
+
known: KnownRule[]
|
|
91
|
+
): TriggeredRule[] {
|
|
84
92
|
const newest = corrections.at(-1);
|
|
85
93
|
if (!newest) return [];
|
|
86
|
-
return known.filter(
|
|
87
|
-
(rule) => rule.status === "approved" && !firesOn(rule.trigger, newest)
|
|
88
|
-
);
|
|
94
|
+
return known.filter(isTriggerRule).filter((rule) => !firesOn(rule.trigger, newest));
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
export function canWiden(corrections: Correction[], known: KnownRule[]): boolean {
|
|
@@ -99,9 +105,11 @@ function correctionBlock(correction: Correction, n: number, newest: boolean): st
|
|
|
99
105
|
|
|
100
106
|
function knownRuleLine(rule: KnownRule, newest: Correction | undefined): string {
|
|
101
107
|
if (rule.widens)
|
|
102
|
-
return `- draft widening of rule ${rule.widens}: ${rule.trigger
|
|
103
|
-
if (rule
|
|
104
|
-
|
|
108
|
+
return `- draft widening of rule ${rule.widens}: ${rule.trigger?.pattern}`;
|
|
109
|
+
if (!isTriggerRule(rule)) {
|
|
110
|
+
const label = rule.delivery === "always" ? `opinion ${rule.id}` : rule.status;
|
|
111
|
+
return `- ${label}: ${rule.when}. ${rule.steering}`;
|
|
112
|
+
}
|
|
105
113
|
const fires = newest && firesOn(rule.trigger, newest) ? "yes" : "no";
|
|
106
114
|
return `- approved rule ${rule.id}: ${rule.when}. ${rule.steering} Trigger on the ${rule.trigger.side}: ${rule.trigger.pattern}. Fires on the newest correction: ${fires}.`;
|
|
107
115
|
}
|
|
@@ -134,7 +142,7 @@ interface DraftOutput {
|
|
|
134
142
|
recurring: boolean;
|
|
135
143
|
widens?: string;
|
|
136
144
|
when: string;
|
|
137
|
-
side: "prompt" | "reply";
|
|
145
|
+
side: "prompt" | "reply" | "";
|
|
138
146
|
pattern: string;
|
|
139
147
|
steering: string;
|
|
140
148
|
check: string;
|
|
@@ -185,23 +193,48 @@ function citedEvidence(cites: number[], corrections: Correction[]): string[] {
|
|
|
185
193
|
});
|
|
186
194
|
}
|
|
187
195
|
|
|
196
|
+
function namedKindsOf(cited: Correction[]): string[] {
|
|
197
|
+
return cited
|
|
198
|
+
.filter(hasCurrentKind)
|
|
199
|
+
.map((c) => c.correctionKind.kind)
|
|
200
|
+
.filter((kind) => kind !== "other");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function citedKind(
|
|
204
|
+
cites: number[],
|
|
205
|
+
corrections: Correction[]
|
|
206
|
+
): string | undefined {
|
|
207
|
+
const newestFirst = namedKindsOf(cites.map((n) => corrections[n])).reverse();
|
|
208
|
+
const count = (kind: string) => newestFirst.filter((k) => k === kind).length;
|
|
209
|
+
const most = Math.max(...newestFirst.map(count));
|
|
210
|
+
return newestFirst.find((kind) => count(kind) === most);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function withKind<T extends CandidateInput>(candidate: T, kind: string | undefined): T {
|
|
214
|
+
return kind ? { ...candidate, kind } : candidate;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function optionalTrigger({ side, pattern }: DraftOutput): RuleTrigger | undefined {
|
|
218
|
+
if ((side !== "prompt" && side !== "reply") || !hasText(pattern)) return undefined;
|
|
219
|
+
const bare = bareExpression(pattern);
|
|
220
|
+
return isValidPattern(bare) ? { side, pattern: bare } : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
188
223
|
function parseNewRule(
|
|
189
224
|
draft: DraftOutput,
|
|
190
225
|
corrections: Correction[]
|
|
191
226
|
): CandidateInput | null {
|
|
192
227
|
const cites = validCites(draft.cites, corrections.length, MIN_CITED);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
if (!isValidPattern(pattern)) return null;
|
|
197
|
-
if (!hasText(draft.when, draft.steering, draft.check)) return null;
|
|
198
|
-
return {
|
|
228
|
+
if (!cites || !hasText(draft.when, draft.steering, draft.check)) return null;
|
|
229
|
+
const trigger = optionalTrigger(draft);
|
|
230
|
+
const candidate = {
|
|
199
231
|
when: draft.when.trim(),
|
|
200
|
-
trigger
|
|
232
|
+
...(trigger && { trigger }),
|
|
201
233
|
steering: draft.steering.trim(),
|
|
202
234
|
check: draft.check.trim(),
|
|
203
235
|
evidence: citedEvidence(cites, corrections),
|
|
204
236
|
};
|
|
237
|
+
return withKind(candidate, citedKind(cites, corrections));
|
|
205
238
|
}
|
|
206
239
|
|
|
207
240
|
function parseWidening(
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
10
|
import { basename, resolve } from "node:path";
|
|
11
|
+
import { opinionsContext } from "./adaptation-rules";
|
|
11
12
|
import { type FailureEntry, readFailures } from "./learning-store";
|
|
12
|
-
import { loadOpinionContext } from "./opinions";
|
|
13
13
|
import { palHome, paths } from "./paths";
|
|
14
14
|
import { type ProjectProgress, readAllProjects, resolveProjectFromCwd } from "./projects";
|
|
15
15
|
import { readFramePrinciples } from "./wisdom";
|
|
@@ -148,7 +148,7 @@ export function getSemiStaticSources(): SemiStaticSource[] {
|
|
|
148
148
|
writesDigest: true,
|
|
149
149
|
load: () => {
|
|
150
150
|
try {
|
|
151
|
-
return
|
|
151
|
+
return opinionsContext();
|
|
152
152
|
} catch {
|
|
153
153
|
return "";
|
|
154
154
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Relationship tab: the rule drafts waiting on the user, what the approved
|
|
3
3
|
* rules did, the candidates still being proven, and the last 30 days of labelled
|
|
4
|
-
* turns. A decision goes through `decideRule`, the same
|
|
5
|
-
* rule-review skill use.
|
|
4
|
+
* turns. A decision goes through `decideRule` or `retireRule`, the same rules the
|
|
5
|
+
* CLI and the rule-review skill use.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { type Candidate, readCandidates } from "../../hooks/lib/adaptation-candidates";
|
|
@@ -10,8 +10,16 @@ import {
|
|
|
10
10
|
type AdaptationRule,
|
|
11
11
|
decideRule,
|
|
12
12
|
readRules,
|
|
13
|
+
retireRule,
|
|
14
|
+
type TriggerProof,
|
|
13
15
|
} from "../../hooks/lib/adaptation-rules";
|
|
14
16
|
import { readTurns, type Turn } from "../../hooks/lib/adaptation-turns";
|
|
17
|
+
import {
|
|
18
|
+
EFFECT_WINDOW_DAYS,
|
|
19
|
+
type OpinionEffect,
|
|
20
|
+
opinionEffectOf,
|
|
21
|
+
} from "../../hooks/lib/correction-rates";
|
|
22
|
+
import { gateAdvice } from "../../hooks/lib/opinion-gate";
|
|
15
23
|
import {
|
|
16
24
|
type RuleEffect,
|
|
17
25
|
readRuleEvents,
|
|
@@ -25,12 +33,21 @@ const RECENT_CORRECTIONS = 10;
|
|
|
25
33
|
|
|
26
34
|
export interface ActiveRule extends AdaptationRule {
|
|
27
35
|
effect: RuleEffect;
|
|
36
|
+
opinionEffect: OpinionEffect | null;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
export interface
|
|
31
|
-
|
|
39
|
+
export interface DraftRule extends AdaptationRule {
|
|
40
|
+
/** Why it cannot be kept as an opinion, and what to do instead. */
|
|
41
|
+
gateAdvice?: string;
|
|
32
42
|
}
|
|
33
43
|
|
|
44
|
+
export type PageDecision = "approved" | "denied" | "opinion" | "retired";
|
|
45
|
+
|
|
46
|
+
export type PipelineCandidate = Candidate & {
|
|
47
|
+
proof: TriggerProof;
|
|
48
|
+
ordinaryNeeded: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
34
51
|
interface TurnStats {
|
|
35
52
|
total: number;
|
|
36
53
|
byReaction: Record<string, number>;
|
|
@@ -40,7 +57,7 @@ interface TurnStats {
|
|
|
40
57
|
|
|
41
58
|
export interface RelationshipView {
|
|
42
59
|
windowDays: number;
|
|
43
|
-
drafts:
|
|
60
|
+
drafts: DraftRule[];
|
|
44
61
|
active: ActiveRule[];
|
|
45
62
|
denied: AdaptationRule[];
|
|
46
63
|
pipeline: PipelineCandidate[];
|
|
@@ -69,33 +86,41 @@ export function relationship(now: Date = new Date()): RelationshipView {
|
|
|
69
86
|
const rules = readRules();
|
|
70
87
|
const turns = readTurns(WINDOW_DAYS, now);
|
|
71
88
|
const effects = ruleEffects(readRuleEvents(WINDOW_DAYS, now), turns);
|
|
89
|
+
const opinionTurns = readTurns(EFFECT_WINDOW_DAYS, now);
|
|
72
90
|
const withStatus = (status: AdaptationRule["status"]) =>
|
|
73
91
|
rules.filter((rule) => rule.status === status);
|
|
74
92
|
return {
|
|
75
93
|
windowDays: WINDOW_DAYS,
|
|
76
|
-
drafts: withStatus("draft")
|
|
94
|
+
drafts: withStatus("draft").map((rule) => {
|
|
95
|
+
const advice = gateAdvice(rule, rules, opinionTurns, now);
|
|
96
|
+
return advice ? { ...rule, gateAdvice: advice } : rule;
|
|
97
|
+
}),
|
|
77
98
|
active: withStatus("approved").map((rule) => ({
|
|
78
99
|
...rule,
|
|
79
100
|
effect: effects[rule.id] ?? NO_EFFECT,
|
|
101
|
+
opinionEffect: opinionEffectOf(rule, rules, opinionTurns, now),
|
|
80
102
|
})),
|
|
81
103
|
denied: withStatus("denied"),
|
|
82
|
-
pipeline: readCandidates()
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
pipeline: readCandidates().flatMap((candidate) =>
|
|
105
|
+
candidate.verdict === "passed" || candidate.verdict === "untriggered"
|
|
106
|
+
? []
|
|
107
|
+
: [
|
|
108
|
+
{
|
|
109
|
+
...candidate,
|
|
110
|
+
ordinaryNeeded:
|
|
111
|
+
candidate.verdict === "waiting"
|
|
112
|
+
? ordinaryTurnsNeeded(candidate.proof)
|
|
113
|
+
: 0,
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
),
|
|
89
117
|
turns: turnStats(turns),
|
|
90
118
|
};
|
|
91
119
|
}
|
|
92
120
|
|
|
93
|
-
export function decideFromPage(
|
|
94
|
-
id: string,
|
|
95
|
-
decision: "approved" | "denied"
|
|
96
|
-
): WriteOutcome {
|
|
121
|
+
export function decideFromPage(id: string, decision: PageDecision): WriteOutcome {
|
|
97
122
|
const known = readRules().some((rule) => rule.id === id);
|
|
98
|
-
const result = decideRule(id, decision);
|
|
123
|
+
const result = decision === "retired" ? retireRule(id) : decideRule(id, decision);
|
|
99
124
|
if (result.ok) return { ok: true, changed: true };
|
|
100
125
|
return { ok: false, status: known ? 409 : 404, error: result.reason };
|
|
101
126
|
}
|
|
@@ -18,7 +18,7 @@ import { agenda, agentsAtWork, board, handoffs, signal, summary } from "./data";
|
|
|
18
18
|
import { projectDetail } from "./detail";
|
|
19
19
|
import { matrix } from "./matrix";
|
|
20
20
|
import { type PrefUpdate, readPrefs, validatePrefs, writePrefs } from "./prefs";
|
|
21
|
-
import { decideFromPage, relationship } from "./relationship";
|
|
21
|
+
import { decideFromPage, type PageDecision, relationship } from "./relationship";
|
|
22
22
|
import { DEFAULT_PORT, LOOPBACK } from "./server-config";
|
|
23
23
|
import { MAX_SNOOZE_DAYS, setSnooze } from "./snooze";
|
|
24
24
|
import { indexHtml, staticAsset } from "./static";
|
|
@@ -290,16 +290,23 @@ async function prefsWrite(request: Request): Promise<Response> {
|
|
|
290
290
|
return failure ? json({ error: failure }, 400) : json(writePrefs(body as PrefUpdate));
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
|
|
293
|
+
const PAGE_DECISIONS: readonly PageDecision[] = [
|
|
294
|
+
"approved",
|
|
295
|
+
"denied",
|
|
296
|
+
"opinion",
|
|
297
|
+
"retired",
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
/** Decide a rule draft or retire a rule — the same rules `pal cli rule` reaches. */
|
|
294
301
|
async function ruleWrite(request: Request): Promise<Response> {
|
|
295
302
|
const body = await readBody(request);
|
|
296
303
|
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
297
304
|
const { id, decision } = body;
|
|
298
305
|
if (typeof id !== "string" || !id) return json({ error: "id is required" }, 400);
|
|
299
|
-
if (decision
|
|
300
|
-
return json({ error:
|
|
306
|
+
if (!PAGE_DECISIONS.includes(decision as PageDecision)) {
|
|
307
|
+
return json({ error: `decision must be one of ${PAGE_DECISIONS.join(", ")}` }, 400);
|
|
301
308
|
}
|
|
302
|
-
return answer(decideFromPage(id, decision), { id, decision });
|
|
309
|
+
return answer(decideFromPage(id, decision as PageDecision), { id, decision });
|
|
303
310
|
}
|
|
304
311
|
|
|
305
312
|
const WRITES: Record<string, (request: Request) => Promise<Response>> = {
|