@danypops/papyrus 0.42.0 → 0.42.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 +2 -2
- package/src/adapters/sqlite-artifact-scope-store.ts +18 -9
- package/src/adapters/sqlite-artifact-store.ts +7 -5
- package/src/adapters/sqlite-discussion-round-store.ts +26 -17
- package/src/adapters/sqlite-gate-runner.ts +1 -1
- package/src/adapters/sqlite-graph-projection-store.ts +14 -10
- package/src/adapters/sqlite-log-store.ts +36 -17
- package/src/adapters/sqlite-note-event-store.ts +20 -16
- package/src/adapters/sqlite-session-identity-store.ts +13 -7
- package/src/adapters/sqlite-task-event-store.ts +29 -21
- package/src/adapters/sqlite-task-focus-store.ts +36 -10
- package/src/adapters/sqlite-task-lease-store.ts +12 -6
- package/src/adapters/sqlite-task-scope-store.ts +25 -14
- package/src/artifact-relationship-view.ts +3 -3
- package/src/artifact-subtree.ts +4 -2
- package/src/authority-registry.ts +2 -1
- package/src/cli.ts +785 -179
- package/src/client.ts +46 -20
- package/src/constants.ts +15 -4
- package/src/daemon-state.ts +4 -12
- package/src/daemon.ts +31 -9
- package/src/db.ts +119 -98
- package/src/discussion-service.ts +109 -44
- package/src/domain/artifact-event.ts +17 -4
- package/src/domain/artifact.ts +3 -1
- package/src/domain/blueprint-definition.ts +26 -31
- package/src/domain/checklist.ts +20 -17
- package/src/domain/discussion.ts +37 -18
- package/src/domain/gate.ts +7 -7
- package/src/domain/log-entry.ts +1 -1
- package/src/domain/note-event.ts +20 -7
- package/src/domain/task-event.ts +17 -7
- package/src/domain-services.ts +241 -110
- package/src/graph-projection-service.ts +34 -8
- package/src/id-migration.ts +17 -4
- package/src/index.ts +16 -11
- package/src/log-service.ts +6 -5
- package/src/log.ts +19 -0
- package/src/modules/discuss.ts +63 -28
- package/src/modules/docs.ts +74 -17
- package/src/modules/graph-projection.ts +20 -9
- package/src/modules/logs.ts +33 -21
- package/src/modules/notes.ts +66 -28
- package/src/modules/playbooks.ts +88 -29
- package/src/modules/rules.ts +57 -15
- package/src/modules/session-identity.ts +6 -2
- package/src/modules/tasks.ts +142 -67
- package/src/note-service.ts +11 -7
- package/src/ops.ts +131 -68
- package/src/playbook-definition.ts +56 -17
- package/src/playbook-execution.ts +13 -3
- package/src/ports/note-event-store.ts +6 -4
- package/src/ports/task-event-store.ts +9 -6
- package/src/ports/task-focus-store.ts +17 -4
- package/src/ports/task-lease-store.ts +9 -4
- package/src/ports/task-scope-store.ts +3 -1
- package/src/service.ts +143 -89
- package/src/session-identity-service.ts +10 -2
- package/src/task-context.ts +28 -16
- package/src/task-execution.ts +4 -12
- package/src/task-graph-view.ts +12 -12
- package/src/task-relationship-view.ts +1 -3
- package/src/task-service.ts +167 -72
- package/src/vehicle/artifact-trash-vehicle.ts +25 -13
- package/src/vehicle/artifact-vehicle-shared.ts +28 -7
- package/src/vehicle/docs-vehicle.ts +48 -16
- package/src/vehicle/notes-vehicle.ts +24 -6
- package/src/vehicle/papyrus-vehicle.ts +14 -3
- package/src/vehicle/playbooks-vehicle.ts +87 -18
- package/src/vehicle/rules-vehicle.ts +58 -21
- package/src/vehicle/tasks-vehicle.ts +366 -54
- package/src/version.ts +1 -1
- package/src/workflow-execution.ts +71 -52
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
* design rationale.
|
|
5
5
|
*/
|
|
6
6
|
import { DISCUSSION_LIST_DEFAULT_LIMIT, DISCUSSION_LIST_MAX_LIMIT, DISCUSSION_MAX_ROUNDS } from "./constants.ts";
|
|
7
|
+
import type { Artifact } from "./domain/artifact.ts";
|
|
8
|
+
import type { ArtifactEventContext } from "./domain/artifact-event.ts";
|
|
7
9
|
import {
|
|
8
10
|
DISCUSSION_SUBTYPE,
|
|
11
|
+
type DiscussionExtra,
|
|
12
|
+
type DiscussionOptionsMode,
|
|
13
|
+
type DiscussionRound,
|
|
9
14
|
isDiscussionArtifact,
|
|
10
15
|
readDiscussionExtra,
|
|
11
16
|
validateDeferReason,
|
|
@@ -14,12 +19,7 @@ import {
|
|
|
14
19
|
validateDiscussionOptions,
|
|
15
20
|
validateSelectedOptions,
|
|
16
21
|
validateSettlement,
|
|
17
|
-
type DiscussionExtra,
|
|
18
|
-
type DiscussionOptionsMode,
|
|
19
|
-
type DiscussionRound,
|
|
20
22
|
} from "./domain/discussion.ts";
|
|
21
|
-
import type { Artifact } from "./domain/artifact.ts";
|
|
22
|
-
import type { ArtifactEventContext } from "./domain/artifact-event.ts";
|
|
23
23
|
import type { AtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
|
|
24
24
|
import type { DiscussionRoundStore } from "./ports/discussion-round-store.ts";
|
|
25
25
|
|
|
@@ -72,7 +72,11 @@ export class Discussions {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/** Validates a freshly-posed choice; undefined when neither field is given (nothing posed), since both/neither is the only valid shape. */
|
|
75
|
-
private validatePosedOptions(
|
|
75
|
+
private validatePosedOptions(
|
|
76
|
+
options: string[] | undefined,
|
|
77
|
+
optionsMode: DiscussionOptionsMode | undefined,
|
|
78
|
+
optionDescriptions: string[] | undefined,
|
|
79
|
+
): { options: string[]; mode: DiscussionOptionsMode; optionDescriptions?: string[] } | undefined {
|
|
76
80
|
if (options === undefined && optionsMode === undefined) return undefined;
|
|
77
81
|
return validateDiscussionOptions(options ?? [], optionsMode ?? "", optionDescriptions);
|
|
78
82
|
}
|
|
@@ -82,25 +86,46 @@ export class Discussions {
|
|
|
82
86
|
const content = validateDiscussionContent(input.content);
|
|
83
87
|
const posed = this.validatePosedOptions(input.options, input.optionsMode, input.optionDescriptions);
|
|
84
88
|
return this.artifacts.atomic(() => {
|
|
85
|
-
const discussion = this.artifacts.create(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
89
|
+
const discussion = this.artifacts.create(
|
|
90
|
+
{
|
|
91
|
+
kind: "task",
|
|
92
|
+
subtype: DISCUSSION_SUBTYPE,
|
|
93
|
+
title: input.title,
|
|
94
|
+
body: input.body ?? "",
|
|
95
|
+
status: "in-progress",
|
|
96
|
+
labels: input.labels,
|
|
97
|
+
extra: {
|
|
98
|
+
discussion: {
|
|
99
|
+
state: "active",
|
|
100
|
+
roundCount: 1,
|
|
101
|
+
...(posed
|
|
102
|
+
? {
|
|
103
|
+
pendingOptions: posed.options,
|
|
104
|
+
pendingOptionsMode: posed.mode,
|
|
105
|
+
...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}),
|
|
106
|
+
}
|
|
107
|
+
: {}),
|
|
108
|
+
},
|
|
97
109
|
},
|
|
98
110
|
},
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
111
|
+
context,
|
|
112
|
+
);
|
|
113
|
+
const round = this.rounds.append(
|
|
114
|
+
{
|
|
115
|
+
discussionId: discussion.id,
|
|
116
|
+
roundNumber: 1,
|
|
117
|
+
actor,
|
|
118
|
+
content,
|
|
119
|
+
...(posed
|
|
120
|
+
? {
|
|
121
|
+
options: posed.options,
|
|
122
|
+
optionsMode: posed.mode,
|
|
123
|
+
...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
|
|
124
|
+
}
|
|
125
|
+
: {}),
|
|
126
|
+
},
|
|
127
|
+
new Date().toISOString(),
|
|
128
|
+
);
|
|
104
129
|
for (const taskId of input.blocksTaskIds ?? []) this.block(discussion.id, taskId, context);
|
|
105
130
|
return { discussion: this.artifacts.get(discussion.id)!, rounds: [round] };
|
|
106
131
|
});
|
|
@@ -114,24 +139,49 @@ export class Discussions {
|
|
|
114
139
|
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
115
140
|
const state = this.extra(discussion);
|
|
116
141
|
if (state.state !== "active") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; resume it before replying`);
|
|
117
|
-
if (state.roundCount >= DISCUSSION_MAX_ROUNDS)
|
|
118
|
-
|
|
142
|
+
if (state.roundCount >= DISCUSSION_MAX_ROUNDS)
|
|
143
|
+
throw new DiscussionError(`discussion "${discussionId}" has reached its ${DISCUSSION_MAX_ROUNDS}-round limit; settle or defer it`);
|
|
144
|
+
const selected =
|
|
145
|
+
input.selected !== undefined ? validateSelectedOptions(input.selected, state.pendingOptions, state.pendingOptionsMode) : undefined;
|
|
119
146
|
const nextRound = state.roundCount + 1;
|
|
120
|
-
const round = this.rounds.append(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
147
|
+
const round = this.rounds.append(
|
|
148
|
+
{
|
|
149
|
+
discussionId,
|
|
150
|
+
roundNumber: nextRound,
|
|
151
|
+
actor: validActor,
|
|
152
|
+
content: validContent,
|
|
153
|
+
...(posed
|
|
154
|
+
? {
|
|
155
|
+
options: posed.options,
|
|
156
|
+
optionsMode: posed.mode,
|
|
157
|
+
...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
|
|
158
|
+
}
|
|
159
|
+
: {}),
|
|
160
|
+
...(selected ? { selected } : {}),
|
|
161
|
+
},
|
|
162
|
+
new Date().toISOString(),
|
|
163
|
+
);
|
|
125
164
|
// Whenever this round answers the pending choice OR poses a new one, the base must drop ALL
|
|
126
165
|
// three pending* fields first -- otherwise a re-pose that omits descriptions this time would
|
|
127
166
|
// leave a stale pendingOptionDescriptions array (sized for the OLD options) spread through
|
|
128
167
|
// unchanged, no longer aligned 1:1 with the new pendingOptions. Only a plain reply that
|
|
129
168
|
// neither answers nor re-poses leaves the existing pending state untouched.
|
|
130
|
-
const {
|
|
169
|
+
const {
|
|
170
|
+
pendingOptions: _clearedOptions,
|
|
171
|
+
pendingOptionsMode: _clearedMode,
|
|
172
|
+
pendingOptionDescriptions: _clearedDescriptions,
|
|
173
|
+
...withoutPending
|
|
174
|
+
} = state;
|
|
131
175
|
const nextState = {
|
|
132
176
|
...(selected || posed ? withoutPending : state),
|
|
133
177
|
roundCount: nextRound,
|
|
134
|
-
...(posed
|
|
178
|
+
...(posed
|
|
179
|
+
? {
|
|
180
|
+
pendingOptions: posed.options,
|
|
181
|
+
pendingOptionsMode: posed.mode,
|
|
182
|
+
...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}),
|
|
183
|
+
}
|
|
184
|
+
: {}),
|
|
135
185
|
};
|
|
136
186
|
const updated = this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: nextState }, context)!;
|
|
137
187
|
return { discussion: updated, rounds: [round] };
|
|
@@ -143,11 +193,16 @@ export class Discussions {
|
|
|
143
193
|
return this.artifacts.atomic(() => {
|
|
144
194
|
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
145
195
|
const state = this.extra(discussion);
|
|
146
|
-
if (state.state !== "active")
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
196
|
+
if (state.state !== "active")
|
|
197
|
+
throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; only an active Discussion can be deferred`);
|
|
198
|
+
return this.artifacts.setExtra(
|
|
199
|
+
discussionId,
|
|
200
|
+
{
|
|
201
|
+
...discussion.extra,
|
|
202
|
+
discussion: { ...state, state: "deferred", ...(validReason === undefined ? {} : { deferredReason: validReason }) },
|
|
203
|
+
},
|
|
204
|
+
context,
|
|
205
|
+
)!;
|
|
151
206
|
});
|
|
152
207
|
}
|
|
153
208
|
|
|
@@ -155,7 +210,8 @@ export class Discussions {
|
|
|
155
210
|
return this.artifacts.atomic(() => {
|
|
156
211
|
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
157
212
|
const state = this.extra(discussion);
|
|
158
|
-
if (state.state !== "deferred")
|
|
213
|
+
if (state.state !== "deferred")
|
|
214
|
+
throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; only a deferred Discussion can be resumed`);
|
|
159
215
|
const { deferredReason: _deferredReason, ...rest } = state;
|
|
160
216
|
return this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: { ...rest, state: "active" } }, context)!;
|
|
161
217
|
});
|
|
@@ -167,10 +223,14 @@ export class Discussions {
|
|
|
167
223
|
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
168
224
|
const state = this.extra(discussion);
|
|
169
225
|
if (state.state === "settled") throw new DiscussionError(`discussion "${discussionId}" is already settled`);
|
|
170
|
-
const updated = this.artifacts.setExtra(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
226
|
+
const updated = this.artifacts.setExtra(
|
|
227
|
+
discussionId,
|
|
228
|
+
{
|
|
229
|
+
...discussion.extra,
|
|
230
|
+
discussion: { ...state, state: "settled", settlement: validSettlement, settledAt: new Date().toISOString() },
|
|
231
|
+
},
|
|
232
|
+
context,
|
|
233
|
+
)!;
|
|
174
234
|
return this.artifacts.setStatus(discussionId, "done", context) ?? updated;
|
|
175
235
|
});
|
|
176
236
|
}
|
|
@@ -178,7 +238,8 @@ export class Discussions {
|
|
|
178
238
|
/** Links an existing active Discussion to a Task it blocks; refuses a non-task target or an already-settled Discussion. */
|
|
179
239
|
block(discussionId: string, taskId: string, context?: ArtifactEventContext): void {
|
|
180
240
|
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
181
|
-
if (this.extra(discussion).state === "settled")
|
|
241
|
+
if (this.extra(discussion).state === "settled")
|
|
242
|
+
throw new DiscussionError(`discussion "${discussionId}" is settled; it can no longer block anything`);
|
|
182
243
|
const task = this.artifacts.get(taskId);
|
|
183
244
|
if (!task) throw new DiscussionError(`task "${taskId}" not found`);
|
|
184
245
|
if (task.kind !== "task" || isDiscussionArtifact(task)) throw new DiscussionError(`artifact "${taskId}" is not a task`);
|
|
@@ -211,7 +272,11 @@ export class Discussions {
|
|
|
211
272
|
const rows = this.artifacts.query({ kind: "task", subtype: DISCUSSION_SUBTYPE, limit });
|
|
212
273
|
if (!filter.state) return rows;
|
|
213
274
|
return rows.filter((row) => {
|
|
214
|
-
try {
|
|
275
|
+
try {
|
|
276
|
+
return this.extra(row).state === filter.state;
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
215
280
|
});
|
|
216
281
|
}
|
|
217
282
|
}
|
|
@@ -10,8 +10,17 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { ARTIFACT_EVENT_ACTOR_MAX_LENGTH, ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT, ARTIFACT_EVENT_HISTORY_MAX_LIMIT } from "../constants.ts";
|
|
12
12
|
|
|
13
|
-
export const ARTIFACT_EVENT_TYPES = [
|
|
14
|
-
|
|
13
|
+
export const ARTIFACT_EVENT_TYPES = [
|
|
14
|
+
"created",
|
|
15
|
+
"updated",
|
|
16
|
+
"status_changed",
|
|
17
|
+
"extra_set",
|
|
18
|
+
"linked",
|
|
19
|
+
"unlinked",
|
|
20
|
+
"trashed",
|
|
21
|
+
"restored",
|
|
22
|
+
] as const;
|
|
23
|
+
export type ArtifactEventType = (typeof ARTIFACT_EVENT_TYPES)[number];
|
|
15
24
|
export type ArtifactEventDirection = "asc" | "desc";
|
|
16
25
|
|
|
17
26
|
/** Caller-supplied identity for a mutation. All fields are advisory (self-reported), not cryptographically verified. */
|
|
@@ -73,7 +82,9 @@ function boundedString(value: string, field: string, maximum: number): string {
|
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/** Fills defaults and enforces bounds. The one place every appended event is normalized. */
|
|
76
|
-
export function resolveArtifactEvent(
|
|
85
|
+
export function resolveArtifactEvent(
|
|
86
|
+
input: AppendArtifactEvent,
|
|
87
|
+
): Required<Pick<AppendArtifactEvent, "actor" | "source">> & AppendArtifactEvent {
|
|
77
88
|
if (!input.artifactId) throw new Error("artifactId is required");
|
|
78
89
|
const actor = boundedString(input.actor ?? ARTIFACT_EVENT_DEFAULT_ACTOR, "actor", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
|
|
79
90
|
const source = boundedString(input.source ?? ARTIFACT_EVENT_DEFAULT_SOURCE, "source", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
|
|
@@ -81,7 +92,9 @@ export function resolveArtifactEvent(input: AppendArtifactEvent): Required<Pick<
|
|
|
81
92
|
return { ...input, actor, source };
|
|
82
93
|
}
|
|
83
94
|
|
|
84
|
-
export function normalizeArtifactEventQuery(
|
|
95
|
+
export function normalizeArtifactEventQuery(
|
|
96
|
+
query: ArtifactEventQuery,
|
|
97
|
+
): Required<Pick<ArtifactEventQuery, "limit" | "direction">> & ArtifactEventQuery {
|
|
85
98
|
if (!query.artifactId && !query.actor && !query.sessionId) {
|
|
86
99
|
throw new Error("artifact event query requires artifactId, actor, or sessionId to stay bounded");
|
|
87
100
|
}
|
package/src/domain/artifact.ts
CHANGED
|
@@ -94,7 +94,9 @@ export function externalSourceOf(artifact: Pick<Artifact, "labels">): string | u
|
|
|
94
94
|
export function requireLocallyOwnedContent(artifact: Artifact): Artifact {
|
|
95
95
|
const system = externalSourceOf(artifact);
|
|
96
96
|
if (system !== undefined) {
|
|
97
|
-
throw new Error(
|
|
97
|
+
throw new Error(
|
|
98
|
+
`"${artifact.title}" is a read-only projection from ${system}; edit it there, or capture a correction as a new linked Doc, until a write-back capability is integrated`,
|
|
99
|
+
);
|
|
98
100
|
}
|
|
99
101
|
return artifact;
|
|
100
102
|
}
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
SEED_RELATIONS,
|
|
3
|
-
SKILL_MAX_BLUEPRINTS,
|
|
4
|
-
SKILL_MAX_ENUM_VALUES,
|
|
5
|
-
SKILL_MAX_INPUTS,
|
|
6
|
-
SKILL_MAX_LINKS,
|
|
7
|
-
} from "../constants.ts";
|
|
1
|
+
import { SEED_RELATIONS, SKILL_MAX_BLUEPRINTS, SKILL_MAX_ENUM_VALUES, SKILL_MAX_INPUTS, SKILL_MAX_LINKS } from "../constants.ts";
|
|
8
2
|
|
|
9
3
|
export type BlueprintArgumentValue = string | number | boolean;
|
|
10
4
|
export type BlueprintInputType = "string" | "number" | "boolean";
|
|
@@ -124,17 +118,18 @@ function validateInputs(value: unknown): Record<string, BlueprintInputDefinition
|
|
|
124
118
|
if (RESERVED_KEYS.has(name)) throw new Error(`reserved input name "${name}"`);
|
|
125
119
|
if (!NAME_PATTERN.test(name)) throw new Error(`invalid input name "${name}"`);
|
|
126
120
|
const input = record(raw, `input "${name}"`);
|
|
127
|
-
if (!BLUEPRINT_INPUT_TYPES.has(input
|
|
128
|
-
const type = input
|
|
129
|
-
if (input
|
|
121
|
+
if (!BLUEPRINT_INPUT_TYPES.has(input.type as BlueprintInputType)) throw new Error(`input "${name}" has unsupported type`);
|
|
122
|
+
const type = input.type as BlueprintInputType;
|
|
123
|
+
if (input.required !== undefined && typeof input.required !== "boolean") {
|
|
130
124
|
throw new Error(`input "${name}" required must be boolean`);
|
|
131
125
|
}
|
|
132
126
|
const normalized: BlueprintInputDefinition = { type };
|
|
133
|
-
if (input
|
|
134
|
-
if (input
|
|
135
|
-
if (input
|
|
136
|
-
const values = array(input
|
|
137
|
-
if (values.length === 0 || values.length > SKILL_MAX_ENUM_VALUES)
|
|
127
|
+
if (input.required !== undefined) normalized.required = input.required as boolean;
|
|
128
|
+
if (input.default !== undefined) normalized.default = validateArgumentValue(name, type, input.default);
|
|
129
|
+
if (input.enum !== undefined) {
|
|
130
|
+
const values = array(input.enum, `input "${name}" enum`);
|
|
131
|
+
if (values.length === 0 || values.length > SKILL_MAX_ENUM_VALUES)
|
|
132
|
+
throw new Error(`input "${name}" enum must contain 1-${SKILL_MAX_ENUM_VALUES} values`);
|
|
138
133
|
normalized.enum = values.map((entry) => validateArgumentValue(name, type, entry));
|
|
139
134
|
if (normalized.default !== undefined && !normalized.enum.includes(normalized.default)) {
|
|
140
135
|
throw new Error(`input "${name}" default must be one of its enum values`);
|
|
@@ -147,9 +142,9 @@ function validateInputs(value: unknown): Record<string, BlueprintInputDefinition
|
|
|
147
142
|
|
|
148
143
|
function validateBlueprint<T extends { ref: string; title: string }>(value: unknown, kind: string): T {
|
|
149
144
|
const source = record(value, `${kind} blueprint`);
|
|
150
|
-
const ref = string(source
|
|
145
|
+
const ref = string(source.ref, `${kind} blueprint ref`);
|
|
151
146
|
if (!NAME_PATTERN.test(ref)) throw new Error(`invalid blueprint ref "${ref}"`);
|
|
152
|
-
const title = string(source
|
|
147
|
+
const title = string(source.title, `${kind} blueprint title`);
|
|
153
148
|
return { ...source, ref, title } as T;
|
|
154
149
|
}
|
|
155
150
|
|
|
@@ -187,22 +182,22 @@ function assertAcyclic(steps: DependentStep[]): void {
|
|
|
187
182
|
|
|
188
183
|
function validateCallBlueprint(value: unknown): CallBlueprint {
|
|
189
184
|
const source = record(value, "call blueprint");
|
|
190
|
-
const ref = string(source
|
|
185
|
+
const ref = string(source.ref, "call blueprint ref");
|
|
191
186
|
if (!NAME_PATTERN.test(ref)) throw new Error(`invalid blueprint ref "${ref}"`);
|
|
192
|
-
const title = string(source
|
|
193
|
-
const targetId = string(source
|
|
187
|
+
const title = string(source.title, "call blueprint title");
|
|
188
|
+
const targetId = string(source.targetId ?? source.skillId, "call blueprint targetId");
|
|
194
189
|
return { ...source, ref, title, targetId } as CallBlueprint;
|
|
195
190
|
}
|
|
196
191
|
|
|
197
192
|
export function validateBlueprintDefinition(value: unknown): BlueprintDefinition {
|
|
198
193
|
const source = record(value, "blueprint definition");
|
|
199
|
-
if (source
|
|
200
|
-
const inputs = validateInputs(source
|
|
201
|
-
const rawBlueprints = record(source
|
|
202
|
-
const docs = array(rawBlueprints
|
|
203
|
-
const rules = array(rawBlueprints
|
|
204
|
-
const tasks = array(rawBlueprints
|
|
205
|
-
const calls = array(rawBlueprints
|
|
194
|
+
if (source.version !== 1) throw new Error("blueprint definition version must be 1");
|
|
195
|
+
const inputs = validateInputs(source.inputs);
|
|
196
|
+
const rawBlueprints = record(source.blueprints, "blueprints");
|
|
197
|
+
const docs = array(rawBlueprints.docs ?? [], "doc blueprints").map((entry) => validateBlueprint<DocBlueprint>(entry, "doc"));
|
|
198
|
+
const rules = array(rawBlueprints.rules ?? [], "rule blueprints").map((entry) => validateBlueprint<RuleBlueprint>(entry, "rule"));
|
|
199
|
+
const tasks = array(rawBlueprints.tasks ?? [], "task blueprints").map((entry) => validateBlueprint<TaskBlueprint>(entry, "task"));
|
|
200
|
+
const calls = array(rawBlueprints.skills ?? [], "call blueprints").map(validateCallBlueprint);
|
|
206
201
|
const all = [...docs, ...rules, ...tasks, ...calls];
|
|
207
202
|
if (all.length === 0 || all.length > SKILL_MAX_BLUEPRINTS) throw new Error(`blueprints must contain 1-${SKILL_MAX_BLUEPRINTS} artifacts`);
|
|
208
203
|
const refs = new Set<string>();
|
|
@@ -237,11 +232,11 @@ export function validateBlueprintDefinition(value: unknown): BlueprintDefinition
|
|
|
237
232
|
for (const name of placeholders(all)) {
|
|
238
233
|
if (!Object.hasOwn(inputs, name)) throw new Error(`unknown input placeholder "${name}"`);
|
|
239
234
|
}
|
|
240
|
-
const links = array(source
|
|
235
|
+
const links = array(source.links ?? [], "links").map((entry) => {
|
|
241
236
|
const link = record(entry, "link");
|
|
242
|
-
const from = string(link
|
|
243
|
-
const relation = string(link
|
|
244
|
-
const to = string(link
|
|
237
|
+
const from = string(link.from, "link from");
|
|
238
|
+
const relation = string(link.relation, "link relation");
|
|
239
|
+
const to = string(link.to, "link to");
|
|
245
240
|
if (!refs.has(from)) throw new Error(`unknown blueprint ref "${from}"`);
|
|
246
241
|
if (!refs.has(to)) throw new Error(`unknown blueprint ref "${to}"`);
|
|
247
242
|
if (!RELATIONS.has(relation)) throw new Error(`unknown link relation "${relation}"`);
|
package/src/domain/checklist.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const PROOF_TYPES = ["file", "symbol", "code", "test", "command", "artifact", "url"] as const;
|
|
2
2
|
|
|
3
|
-
export type ProofType = typeof PROOF_TYPES[number];
|
|
3
|
+
export type ProofType = (typeof PROOF_TYPES)[number];
|
|
4
4
|
|
|
5
5
|
export interface ProofReference {
|
|
6
6
|
type: ProofType;
|
|
@@ -25,13 +25,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function proofReference(value: unknown): ProofReference | undefined {
|
|
28
|
-
if (!isRecord(value) || !PROOF_TYPES.includes(value
|
|
29
|
-
if (typeof value
|
|
30
|
-
if (value
|
|
28
|
+
if (!isRecord(value) || !PROOF_TYPES.includes(value.type as ProofType)) return undefined;
|
|
29
|
+
if (typeof value.target !== "string" || value.target.trim().length === 0) return undefined;
|
|
30
|
+
if (value.expect !== undefined && typeof value.expect !== "string") return undefined;
|
|
31
31
|
return {
|
|
32
|
-
type: value
|
|
33
|
-
target: value
|
|
34
|
-
...(typeof value
|
|
32
|
+
type: value.type as ProofType,
|
|
33
|
+
target: value.target,
|
|
34
|
+
...(typeof value.expect === "string" ? { expect: value.expect } : {}),
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -40,10 +40,10 @@ export function validateChecklist(value: unknown): Checklist {
|
|
|
40
40
|
const checklist: Checklist = {};
|
|
41
41
|
for (const [item, criterion] of Object.entries(value)) {
|
|
42
42
|
if (item.trim().length === 0) throw new Error("checklist item must not be empty");
|
|
43
|
-
if (!isRecord(criterion) || !Array.isArray(criterion
|
|
43
|
+
if (!isRecord(criterion) || !Array.isArray(criterion.proof) || criterion.proof.length === 0) {
|
|
44
44
|
throw new Error(`checklist item "${item}" requires at least one proof reference`);
|
|
45
45
|
}
|
|
46
|
-
const proof = criterion
|
|
46
|
+
const proof = criterion.proof.map(proofReference);
|
|
47
47
|
if (proof.some((reference) => reference === undefined)) {
|
|
48
48
|
throw new Error(`checklist item "${item}" requires a typed, non-empty proof target`);
|
|
49
49
|
}
|
|
@@ -54,17 +54,20 @@ export function validateChecklist(value: unknown): Checklist {
|
|
|
54
54
|
|
|
55
55
|
export function checklistEntries(value: unknown): ChecklistEntry[] {
|
|
56
56
|
if (Array.isArray(value)) {
|
|
57
|
-
return value.flatMap((item) =>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
return value.flatMap((item) =>
|
|
58
|
+
typeof item === "string"
|
|
59
|
+
? [{ item, proof: [], legacy: true }]
|
|
60
|
+
: isRecord(item) && typeof item.title === "string"
|
|
61
|
+
? [{ item: item.title, proof: [], legacy: true }]
|
|
62
|
+
: [],
|
|
63
|
+
);
|
|
62
64
|
}
|
|
63
65
|
if (!isRecord(value)) return [];
|
|
64
66
|
return Object.entries(value).map(([item, criterion]) => {
|
|
65
|
-
const references =
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
const references =
|
|
68
|
+
isRecord(criterion) && Array.isArray(criterion.proof)
|
|
69
|
+
? criterion.proof.map(proofReference).filter((proof): proof is ProofReference => proof !== undefined)
|
|
70
|
+
: [];
|
|
68
71
|
return { item, proof: references, legacy: false };
|
|
69
72
|
});
|
|
70
73
|
}
|
package/src/domain/discussion.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
export const DISCUSSION_SUBTYPE = "discussion";
|
|
33
33
|
|
|
34
34
|
export const DISCUSSION_STATES = ["active", "deferred", "settled"] as const;
|
|
35
|
-
export type DiscussionState = typeof DISCUSSION_STATES[number];
|
|
35
|
+
export type DiscussionState = (typeof DISCUSSION_STATES)[number];
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* A round can pose a choice (options + optionsMode) the way opencode's QuestionV2 poses
|
|
@@ -40,7 +40,7 @@ export type DiscussionState = typeof DISCUSSION_STATES[number];
|
|
|
40
40
|
* (exactly one pick); "multi" allows several -- see constants.ts.
|
|
41
41
|
*/
|
|
42
42
|
export const DISCUSSION_OPTIONS_MODES = ["single", "multi"] as const;
|
|
43
|
-
export type DiscussionOptionsMode = typeof DISCUSSION_OPTIONS_MODES[number];
|
|
43
|
+
export type DiscussionOptionsMode = (typeof DISCUSSION_OPTIONS_MODES)[number];
|
|
44
44
|
|
|
45
45
|
/** Persisted in a discussion Doc's `extra.discussion`. pendingOptions/-Mode is the current-state cache of "is there an unanswered posed choice right now" -- cleared once answered, set again whenever a round poses a new one. */
|
|
46
46
|
export interface DiscussionExtra {
|
|
@@ -113,7 +113,11 @@ export function validateSettlement(settlement: string): string {
|
|
|
113
113
|
/** Validates a freshly-posed choice: 2..DISCUSSION_OPTIONS_MAX_COUNT unique, bounded-length
|
|
114
114
|
* options, a real mode, and a non-empty description for every option once
|
|
115
115
|
* DISCUSSION_OPTION_DESCRIPTION_REQUIRED_FROM_COUNT or more are posed. */
|
|
116
|
-
export function validateDiscussionOptions(
|
|
116
|
+
export function validateDiscussionOptions(
|
|
117
|
+
options: string[],
|
|
118
|
+
mode: string,
|
|
119
|
+
optionDescriptions?: string[],
|
|
120
|
+
): { options: string[]; mode: DiscussionOptionsMode; optionDescriptions?: string[] } {
|
|
117
121
|
if (!(DISCUSSION_OPTIONS_MODES as readonly string[]).includes(mode)) {
|
|
118
122
|
throw new Error(`options_mode must be one of ${DISCUSSION_OPTIONS_MODES.join(", ")}`);
|
|
119
123
|
}
|
|
@@ -123,20 +127,35 @@ export function validateDiscussionOptions(options: string[], mode: string, optio
|
|
|
123
127
|
for (const option of options) boundedString(option, "option", DISCUSSION_OPTION_MAX_LENGTH);
|
|
124
128
|
if (new Set(options).size !== options.length) throw new Error("options must not repeat an entry");
|
|
125
129
|
const descriptionsRequired = options.length >= DISCUSSION_OPTION_DESCRIPTION_REQUIRED_FROM_COUNT;
|
|
126
|
-
if (
|
|
127
|
-
|
|
130
|
+
if (
|
|
131
|
+
descriptionsRequired &&
|
|
132
|
+
(optionDescriptions === undefined || optionDescriptions.some((description) => description.trim().length === 0))
|
|
133
|
+
) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`option_descriptions is required, with a non-empty entry for every option, once ${DISCUSSION_OPTION_DESCRIPTION_REQUIRED_FROM_COUNT} or more options are posed`,
|
|
136
|
+
);
|
|
128
137
|
}
|
|
129
138
|
if (optionDescriptions !== undefined) {
|
|
130
|
-
if (optionDescriptions.length !== options.length)
|
|
139
|
+
if (optionDescriptions.length !== options.length)
|
|
140
|
+
throw new Error("option_descriptions must have exactly one entry per option (use an empty string for none)");
|
|
131
141
|
for (const description of optionDescriptions) {
|
|
132
|
-
if (description.length > DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH)
|
|
142
|
+
if (description.length > DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH)
|
|
143
|
+
throw new Error(`option description must be at most ${DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH} characters`);
|
|
133
144
|
}
|
|
134
145
|
}
|
|
135
|
-
return {
|
|
146
|
+
return {
|
|
147
|
+
options: [...options],
|
|
148
|
+
mode: mode as DiscussionOptionsMode,
|
|
149
|
+
...(optionDescriptions !== undefined ? { optionDescriptions: [...optionDescriptions] } : {}),
|
|
150
|
+
};
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
/** Validates an answer against the Discussion's currently pending posed choice, if any. */
|
|
139
|
-
export function validateSelectedOptions(
|
|
154
|
+
export function validateSelectedOptions(
|
|
155
|
+
selected: string[],
|
|
156
|
+
pendingOptions: string[] | undefined,
|
|
157
|
+
pendingMode: DiscussionOptionsMode | undefined,
|
|
158
|
+
): string[] {
|
|
140
159
|
if (!pendingOptions || pendingOptions.length === 0 || !pendingMode) {
|
|
141
160
|
throw new Error("this Discussion has no pending options to select from");
|
|
142
161
|
}
|
|
@@ -155,26 +174,26 @@ export function isDiscussionArtifact(artifact: { kind: string; subtype: string }
|
|
|
155
174
|
|
|
156
175
|
/** Reads and defensively validates the extra.discussion shape; throws on a corrupt/foreign shape rather than silently treating it as some default state. */
|
|
157
176
|
export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionExtra {
|
|
158
|
-
const raw = extra
|
|
177
|
+
const raw = extra.discussion;
|
|
159
178
|
if (typeof raw !== "object" || raw === null) throw new Error("artifact is not a Discussion (missing extra.discussion)");
|
|
160
179
|
const record = raw as Record<string, unknown>;
|
|
161
|
-
const state = record
|
|
180
|
+
const state = record.state;
|
|
162
181
|
if (typeof state !== "string" || !(DISCUSSION_STATES as readonly string[]).includes(state)) {
|
|
163
182
|
throw new Error(`invalid Discussion state "${String(state)}"`);
|
|
164
183
|
}
|
|
165
|
-
const roundCount = record
|
|
184
|
+
const roundCount = record.roundCount;
|
|
166
185
|
if (typeof roundCount !== "number" || !Number.isInteger(roundCount) || roundCount < 0) {
|
|
167
186
|
throw new Error("invalid Discussion roundCount");
|
|
168
187
|
}
|
|
169
|
-
const pendingOptions = record
|
|
188
|
+
const pendingOptions = record.pendingOptions;
|
|
170
189
|
if (pendingOptions !== undefined && (!Array.isArray(pendingOptions) || pendingOptions.some((entry) => typeof entry !== "string"))) {
|
|
171
190
|
throw new Error("invalid Discussion pendingOptions");
|
|
172
191
|
}
|
|
173
|
-
const pendingOptionsMode = record
|
|
192
|
+
const pendingOptionsMode = record.pendingOptionsMode;
|
|
174
193
|
if (pendingOptionsMode !== undefined && !(DISCUSSION_OPTIONS_MODES as readonly unknown[]).includes(pendingOptionsMode)) {
|
|
175
194
|
throw new Error("invalid Discussion pendingOptionsMode");
|
|
176
195
|
}
|
|
177
|
-
const pendingOptionDescriptions = record
|
|
196
|
+
const pendingOptionDescriptions = record.pendingOptionDescriptions;
|
|
178
197
|
if (pendingOptionDescriptions !== undefined) {
|
|
179
198
|
if (!Array.isArray(pendingOptionDescriptions) || pendingOptionDescriptions.some((entry) => typeof entry !== "string")) {
|
|
180
199
|
throw new Error("invalid Discussion pendingOptionDescriptions");
|
|
@@ -186,9 +205,9 @@ export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionE
|
|
|
186
205
|
return {
|
|
187
206
|
state: state as DiscussionState,
|
|
188
207
|
roundCount,
|
|
189
|
-
...(typeof record
|
|
190
|
-
...(typeof record
|
|
191
|
-
...(typeof record
|
|
208
|
+
...(typeof record.deferredReason === "string" ? { deferredReason: record.deferredReason } : {}),
|
|
209
|
+
...(typeof record.settlement === "string" ? { settlement: record.settlement } : {}),
|
|
210
|
+
...(typeof record.settledAt === "string" ? { settledAt: record.settledAt } : {}),
|
|
192
211
|
...(pendingOptions !== undefined ? { pendingOptions: pendingOptions as string[] } : {}),
|
|
193
212
|
...(pendingOptionsMode !== undefined ? { pendingOptionsMode: pendingOptionsMode as DiscussionOptionsMode } : {}),
|
|
194
213
|
...(pendingOptionDescriptions !== undefined ? { pendingOptionDescriptions: pendingOptionDescriptions as string[] } : {}),
|
package/src/domain/gate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const GATE_TYPES = ["file-exists", "command", "contains", "test"] as const;
|
|
2
|
-
export type GateType = typeof GATE_TYPES[number];
|
|
2
|
+
export type GateType = (typeof GATE_TYPES)[number];
|
|
3
3
|
|
|
4
4
|
export interface Gate {
|
|
5
5
|
type: GateType;
|
|
@@ -20,19 +20,19 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
20
20
|
export function validateGates(value: unknown): Gate[] {
|
|
21
21
|
if (!Array.isArray(value)) throw new Error("gates must be an array");
|
|
22
22
|
return value.map((entry, index) => {
|
|
23
|
-
if (!isRecord(entry) || !GATE_TYPES.includes(entry
|
|
23
|
+
if (!isRecord(entry) || !GATE_TYPES.includes(entry.type as GateType)) {
|
|
24
24
|
throw new Error(`gate at index ${index} requires a valid type (${GATE_TYPES.join(", ")})`);
|
|
25
25
|
}
|
|
26
|
-
if (typeof entry
|
|
26
|
+
if (typeof entry.target !== "string" || entry.target.trim().length === 0) {
|
|
27
27
|
throw new Error(`gate at index ${index} requires a non-empty target`);
|
|
28
28
|
}
|
|
29
|
-
if (entry
|
|
29
|
+
if (entry.expect !== undefined && typeof entry.expect !== "string") {
|
|
30
30
|
throw new Error(`gate at index ${index} expect must be a string`);
|
|
31
31
|
}
|
|
32
32
|
return {
|
|
33
|
-
type: entry
|
|
34
|
-
target: entry
|
|
35
|
-
...(typeof entry
|
|
33
|
+
type: entry.type as GateType,
|
|
34
|
+
target: entry.target,
|
|
35
|
+
...(typeof entry.expect === "string" ? { expect: entry.expect } : {}),
|
|
36
36
|
};
|
|
37
37
|
});
|
|
38
38
|
}
|
package/src/domain/log-entry.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
export const LOG_LEVELS = ["debug", "info", "warning", "error"] as const;
|
|
21
|
-
export type LogLevel = typeof LOG_LEVELS[number];
|
|
21
|
+
export type LogLevel = (typeof LOG_LEVELS)[number];
|
|
22
22
|
|
|
23
23
|
export const LOG_SOURCE_ID_MAX_LENGTH = 256;
|
|
24
24
|
export const LOG_MESSAGE_MAX_CHARACTERS = 4000;
|