@dieulc/workflow 0.7.0 → 0.8.1

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.
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Workflow Extension — Persisted Default Model
3
+ *
4
+ * Pi keeps a "saved default model" (`defaultProvider` / `defaultModel` /
5
+ * `defaultThinkingLevel` in settings.json), but only an explicit gesture
6
+ * updates it: Ctrl+S in the model selector calls
7
+ * `session.setModel(model, { persist: true })`, which writes
8
+ * `setDefaultModelAndProvider` (see `dist/core/agent-session.js`). Picking a
9
+ * model any other way is session-only, and the extension's own
10
+ * `pi.setModel()` calls are documented as "without changing the configured
11
+ * default for new sessions".
12
+ *
13
+ * This module is the extension-owned record of what the NEXT session should
14
+ * start on, so the behaviour is identical in terminal Pi and Pi Desktop and
15
+ * survives Pi upgrades (it is our key, not Pi's schema):
16
+ *
17
+ * settings.json
18
+ * workflow.modelDefault = { provider, id, thinking? } ← authoritative
19
+ * defaultProvider / defaultModel / defaultThinkingLevel ← mirrored for Pi
20
+ *
21
+ * The mirror matters because Pi itself only reads its own keys, and because
22
+ * Pi's startup resolution prefers the FIRST `enabledModels` entry over a
23
+ * saved default (`findInitialModel` in `dist/core/model-resolver.js`). The
24
+ * mirror keeps terminal Pi, Pi Desktop and Pi's settings UI in agreement; the
25
+ * extension's `session_start` apply closes the `enabledModels` gap.
26
+ *
27
+ * Pure, I/O-light module — unit-testable without a Pi runtime, mirroring the
28
+ * settings discipline of commit-policy.ts / profiles.ts: read-modify-write
29
+ * that preserves every unrelated key (Pi owns settings.json — we must never
30
+ * clobber it), and a loader that never throws and never rewrites a file it
31
+ * could not understand.
32
+ */
33
+
34
+ import * as fs from "node:fs";
35
+ import * as path from "node:path";
36
+
37
+ import type { ThinkingLevel } from "./roles.ts";
38
+ import { isValidThinkingLevel } from "./roles.ts";
39
+ import { COMMIT_CONFIG_DIR, WORKFLOW_SETTINGS_KEY } from "./commit-policy.ts";
40
+
41
+ // ── Types ────────────────────────────────────────────────────────────
42
+
43
+ /** Nested key under WORKFLOW_SETTINGS_KEY holding the persisted default. */
44
+ export const MODEL_DEFAULT_KEY = "modelDefault";
45
+
46
+ /** A model reference plus the thinking level to restore with it. */
47
+ export interface PersistedModelDefault {
48
+ provider: string;
49
+ id: string;
50
+ thinking?: ThinkingLevel;
51
+ }
52
+
53
+ export interface ModelDefaultSettingPaths {
54
+ global: string;
55
+ project: string;
56
+ }
57
+
58
+ /** Which settings scope supplied the effective value. */
59
+ export type ModelDefaultSource = "default" | "global" | "project";
60
+
61
+ export interface ResolvedModelDefault {
62
+ /** The effective persisted default, or undefined when none is configured. */
63
+ value: PersistedModelDefault | undefined;
64
+ /** Which files contributed (for /role list and /profile status). */
65
+ sources: {
66
+ global: boolean;
67
+ project: boolean;
68
+ effective: ModelDefaultSource;
69
+ };
70
+ }
71
+
72
+ /** Minimal JSON object shape used by the read-modify-write helpers. */
73
+ type JsonObject = Record<string, unknown>;
74
+
75
+ // ── Validation ───────────────────────────────────────────────────────
76
+
77
+ /** Trim a candidate string; empty / non-string → undefined. */
78
+ function cleanString(value: unknown): string | undefined {
79
+ if (typeof value !== "string") return undefined;
80
+ const trimmed = value.trim();
81
+ return trimmed ? trimmed : undefined;
82
+ }
83
+
84
+ /**
85
+ * Coerce one model reference from untrusted JSON. Returns undefined unless
86
+ * `provider` and `id` are non-empty strings; a missing or unrecognised
87
+ * `thinking` is dropped rather than rejecting the whole record (a hand-edited
88
+ * file should still load its model).
89
+ */
90
+ export function coerceModelDefault(
91
+ raw: unknown,
92
+ ): PersistedModelDefault | undefined {
93
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
94
+ const rec = raw as JsonObject;
95
+ const provider = cleanString(rec.provider);
96
+ const id = cleanString(rec.id);
97
+ if (!provider || !id) return undefined;
98
+ const thinkingRaw = cleanString(rec.thinking);
99
+ const thinking =
100
+ thinkingRaw && isValidThinkingLevel(thinkingRaw.toLowerCase())
101
+ ? (thinkingRaw.toLowerCase() as ThinkingLevel)
102
+ : undefined;
103
+ return thinking ? { provider, id, thinking } : { provider, id };
104
+ }
105
+
106
+ /**
107
+ * Compare two model references. Provider + id must match; `thinking` is only
108
+ * compared when BOTH sides define one, so "same model, unknown thinking" does
109
+ * not fabricate a difference (and therefore does not trigger a rewrite).
110
+ */
111
+ export function sameModelRef(
112
+ a: { provider: string; id: string; thinking?: string } | undefined,
113
+ b: { provider: string; id: string; thinking?: string } | undefined,
114
+ ): boolean {
115
+ if (!a || !b) return false;
116
+ if (a.provider !== b.provider || a.id !== b.id) return false;
117
+ if (a.thinking && b.thinking) return a.thinking === b.thinking;
118
+ return true;
119
+ }
120
+
121
+ /** Canonical `provider/id` label (no thinking suffix). */
122
+ export function formatPersistedModel(m: {
123
+ provider: string;
124
+ id: string;
125
+ }): string {
126
+ return `${m.provider}/${m.id}`;
127
+ }
128
+
129
+ // ── Paths + reading ──────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Settings file locations. `agentDir` is passed in rather than read from the
133
+ * Pi SDK so this stays pure and testable (same contract as
134
+ * defaultProfileSettingPaths / commitConfigPaths).
135
+ */
136
+ export function modelDefaultSettingPaths(
137
+ cwd: string,
138
+ agentDir: string,
139
+ ): ModelDefaultSettingPaths {
140
+ return {
141
+ global: path.join(agentDir, "settings.json"),
142
+ project: path.join(cwd, COMMIT_CONFIG_DIR, "settings.json"),
143
+ };
144
+ }
145
+
146
+ function readSettingsSafe(fp: string): JsonObject | undefined {
147
+ try {
148
+ if (!fs.existsSync(fp)) return undefined;
149
+ const parsed: unknown = JSON.parse(fs.readFileSync(fp, "utf8"));
150
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
151
+ return undefined;
152
+ return parsed as JsonObject;
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Extract `workflow.modelDefault` from a raw settings object. Anything that is
160
+ * not a valid `{provider, id}` record (absent, wrong shape, blank strings)
161
+ * reads as undefined.
162
+ */
163
+ export function extractModelDefault(
164
+ settings: unknown,
165
+ ): PersistedModelDefault | undefined {
166
+ if (!settings || typeof settings !== "object" || Array.isArray(settings))
167
+ return undefined;
168
+ const wf = (settings as JsonObject)[WORKFLOW_SETTINGS_KEY];
169
+ if (!wf || typeof wf !== "object" || Array.isArray(wf)) return undefined;
170
+ return coerceModelDefault((wf as JsonObject)[MODEL_DEFAULT_KEY]);
171
+ }
172
+
173
+ /**
174
+ * Resolve the effective persisted default from disk. Project wins over global;
175
+ * a corrupt or missing file reads as absent. Never throws.
176
+ */
177
+ export function readModelDefault(
178
+ cwd: string,
179
+ agentDir: string,
180
+ ): ResolvedModelDefault {
181
+ const paths = modelDefaultSettingPaths(cwd, agentDir);
182
+ const globalValue = extractModelDefault(readSettingsSafe(paths.global));
183
+ const projectValue = extractModelDefault(readSettingsSafe(paths.project));
184
+ const value = projectValue ?? globalValue;
185
+ return {
186
+ value,
187
+ sources: {
188
+ global: globalValue !== undefined,
189
+ project: projectValue !== undefined,
190
+ effective:
191
+ projectValue !== undefined
192
+ ? "project"
193
+ : globalValue !== undefined
194
+ ? "global"
195
+ : "default",
196
+ },
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Read Pi's OWN project-level default (`<cwd>/.pi/settings.json` top-level
202
+ * `defaultProvider`/`defaultModel`), i.e. a default configured for this
203
+ * project rather than by this extension.
204
+ *
205
+ * Pi deep-merges the project settings file over the global one, so a project
206
+ * value wins when Pi resolves a startup model. The extension must agree with
207
+ * that instead of overwriting it — see
208
+ * `shouldApplyModelDefaultAtSessionStart`'s `project-default` branch.
209
+ *
210
+ * Only the project file is consulted: the global file is already covered by
211
+ * `pi.getSettings()`, and mixing the two here would make a global default look
212
+ * project-scoped. Missing/corrupt/half-specified settings read as absent and
213
+ * never throw.
214
+ */
215
+ export function projectDefaultModel(
216
+ cwd: string,
217
+ agentDir: string,
218
+ ): PersistedModelDefault | undefined {
219
+ const paths = modelDefaultSettingPaths(cwd, agentDir);
220
+ const root = readSettingsSafe(paths.project);
221
+ if (!root) return undefined;
222
+ return coerceModelDefault({
223
+ provider: root.defaultProvider,
224
+ id: root.defaultModel,
225
+ thinking: root.defaultThinkingLevel,
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Which scope a write should target: the project file when it already defines
231
+ * `workflow.modelDefault` (so an existing project override stays effective),
232
+ * otherwise global. Mirrors the "write where the effective value lives" rule
233
+ * used by the app's model-scope writer.
234
+ */
235
+ export function modelDefaultWriteScope(
236
+ cwd: string,
237
+ agentDir: string,
238
+ ): "global" | "project" {
239
+ const paths = modelDefaultSettingPaths(cwd, agentDir);
240
+ return extractModelDefault(readSettingsSafe(paths.project)) !== undefined
241
+ ? "project"
242
+ : "global";
243
+ }
244
+
245
+ // ── Writing ──────────────────────────────────────────────────────────
246
+
247
+ /** Result of a settings write. `changed` distinguishes a no-op save. */
248
+ export interface ModelDefaultWriteResult {
249
+ path: string;
250
+ ok: boolean;
251
+ /** False when the file already held exactly this value (no write needed). */
252
+ changed: boolean;
253
+ error?: string;
254
+ }
255
+
256
+ /**
257
+ * Read-modify-write the persisted default into a settings file.
258
+ *
259
+ * - `value === null` clears `workflow.modelDefault` AND the mirrored
260
+ * `defaultProvider` / `defaultModel` / `defaultThinkingLevel` keys.
261
+ * - A value writes the extension key and mirrors the three Pi keys, so Pi's
262
+ * own startup resolution and settings UI agree with us.
263
+ * - Every other key is preserved, including unrelated `workflow.*` entries
264
+ * (commitPolicy, writePolicy, defaultProfile, reviewMode, …). An empty
265
+ * `workflow` object is removed to keep the file tidy.
266
+ * - Idempotent: writing a value the file already holds performs no write and
267
+ * reports `changed: false`.
268
+ */
269
+ export function writeModelDefault(
270
+ scope: "global" | "project",
271
+ cwd: string,
272
+ agentDir: string,
273
+ value: PersistedModelDefault | null,
274
+ ): ModelDefaultWriteResult {
275
+ const paths = modelDefaultSettingPaths(cwd, agentDir);
276
+ const fp = scope === "project" ? paths.project : paths.global;
277
+ try {
278
+ const existing = readSettingsSafe(fp);
279
+ const root: JsonObject = existing ? { ...existing } : {};
280
+ const wfExisting = root[WORKFLOW_SETTINGS_KEY];
281
+ const wf: JsonObject =
282
+ wfExisting && typeof wfExisting === "object" && !Array.isArray(wfExisting)
283
+ ? { ...(wfExisting as JsonObject) }
284
+ : {};
285
+
286
+ if (value === null) {
287
+ delete wf[MODEL_DEFAULT_KEY];
288
+ delete root.defaultProvider;
289
+ delete root.defaultModel;
290
+ delete root.defaultThinkingLevel;
291
+ } else {
292
+ const coerced = coerceModelDefault(value);
293
+ if (!coerced) {
294
+ return {
295
+ path: fp,
296
+ ok: false,
297
+ changed: false,
298
+ error: "modelDefault needs a non-empty provider and id",
299
+ };
300
+ }
301
+ wf[MODEL_DEFAULT_KEY] = coerced.thinking
302
+ ? {
303
+ provider: coerced.provider,
304
+ id: coerced.id,
305
+ thinking: coerced.thinking,
306
+ }
307
+ : { provider: coerced.provider, id: coerced.id };
308
+ // Mirror for Pi itself (native startup + settings UI).
309
+ root.defaultProvider = coerced.provider;
310
+ root.defaultModel = coerced.id;
311
+ if (coerced.thinking) root.defaultThinkingLevel = coerced.thinking;
312
+ else delete root.defaultThinkingLevel;
313
+ }
314
+
315
+ if (Object.keys(wf).length === 0) delete root[WORKFLOW_SETTINGS_KEY];
316
+ else root[WORKFLOW_SETTINGS_KEY] = wf;
317
+
318
+ const nextText = JSON.stringify(root, null, 2) + "\n";
319
+ const previousText = fs.existsSync(fp)
320
+ ? fs.readFileSync(fp, "utf8")
321
+ : undefined;
322
+ if (previousText === nextText) {
323
+ return { path: fp, ok: true, changed: false };
324
+ }
325
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
326
+ fs.writeFileSync(fp, nextText, "utf8");
327
+ return { path: fp, ok: true, changed: true };
328
+ } catch (e: unknown) {
329
+ return {
330
+ path: fp,
331
+ ok: false,
332
+ changed: false,
333
+ error: e instanceof Error ? e.message : String(e),
334
+ };
335
+ }
336
+ }
337
+
338
+ // ── Session-start apply rule ─────────────────────────────────────────
339
+
340
+ /** Why a session did or did not adopt the persisted default. */
341
+ export type ModelDefaultApplyReason =
342
+ | "apply"
343
+ | "reason-not-new"
344
+ | "subagent"
345
+ | "no-persisted"
346
+ | "project-default"
347
+ | "live-is-persisted"
348
+ | "live-is-explicit";
349
+
350
+ /**
351
+ * Who supplied the Default-mode model this session remembers.
352
+ *
353
+ * `entry` is a resumed session's own recorded model (Pi's precedence, and the
354
+ * user's chosen behaviour); `persisted` is `workflow.modelDefault`; `profile`
355
+ * is the legacy fallback (a profile's `defaultModel` when no record exists).
356
+ */
357
+ export type DefaultModeModelSource = "entry" | "persisted" | "profile" | "none";
358
+
359
+ export interface DefaultModeModelResolution {
360
+ /** What `defaultModel` should hold in memory (source for mode restore). */
361
+ inMemory: PersistedModelDefault | undefined;
362
+ /**
363
+ * What a brand-new session should adopt. The session entry is deliberately
364
+ * excluded: a resumed session never re-applies, and a brand-new one has no
365
+ * entry — so including it could only ever produce a stale target.
366
+ */
367
+ applyTarget: PersistedModelDefault | undefined;
368
+ /** Which input supplied `inMemory` (diagnostics / display). */
369
+ source: DefaultModeModelSource;
370
+ }
371
+
372
+ /**
373
+ * Resolve the Default-mode model for a session start.
374
+ *
375
+ * Precedence (confirmed with the user):
376
+ * in-memory = session entry -> workflow.modelDefault -> profile seed
377
+ * apply = workflow.modelDefault -> profile seed
378
+ *
379
+ * The profile's `defaultModel` is a SEED consumed when the profile is
380
+ * activated, never live state re-applied over a newer explicit choice. Without
381
+ * this rule an active profile silently reset the user's last model pick on
382
+ * every new session.
383
+ */
384
+ export function resolveDefaultModeModel(input: {
385
+ entryModel?: PersistedModelDefault;
386
+ persistedModel?: PersistedModelDefault;
387
+ profileModel?: PersistedModelDefault;
388
+ }): DefaultModeModelResolution {
389
+ const inMemory =
390
+ input.entryModel ?? input.persistedModel ?? input.profileModel;
391
+ const applyTarget = input.persistedModel ?? input.profileModel;
392
+ let source: DefaultModeModelSource = "none";
393
+ if (input.entryModel) source = "entry";
394
+ else if (input.persistedModel) source = "persisted";
395
+ else if (input.profileModel) source = "profile";
396
+ return { inMemory, applyTarget, source };
397
+ }
398
+
399
+ export interface ModelDefaultApplyInput {
400
+ /** `session_start` event reason: startup | new | resume | reload. */
401
+ reason: string | undefined;
402
+ /** `process.env.PI_WORKFLOW_ROLE` — set on workflow's spawned subagents. */
403
+ subagentRoleEnv: string | undefined;
404
+ /** The live model the session is starting with, if any. */
405
+ live: { provider: string; id: string } | undefined;
406
+ /** `ctx.scopedModels[0]`, i.e. Pi's "first enabled model" preference. */
407
+ scopedFirst: { provider: string; id: string } | undefined;
408
+ /** Pi's own saved default (`defaultProvider`/`defaultModel`). */
409
+ settingsDefault: { provider: string; id: string } | undefined;
410
+ /**
411
+ * Pi's PROJECT-level default (`<cwd>/.pi/settings.json`). An explicit
412
+ * project configuration wins inside its project (user decision).
413
+ */
414
+ projectDefault?: PersistedModelDefault | undefined;
415
+ /** Our persisted record. */
416
+ persisted: PersistedModelDefault | undefined;
417
+ }
418
+
419
+ export interface ModelDefaultApplyDecision {
420
+ apply: boolean;
421
+ reason: ModelDefaultApplyReason;
422
+ }
423
+
424
+ /**
425
+ * Decide whether a brand-new session should adopt the persisted default.
426
+ *
427
+ * Guarantees (each branch is unit-tested):
428
+ * - `resume` / `reload` never apply — a resumed session keeps its recorded
429
+ * model, which is Pi's own precedence.
430
+ * - Workflow's spawned subagents (`PI_WORKFLOW_ROLE`, e.g. `pi --mode json
431
+ * --model <role model>`) never apply — otherwise every explore/reviewer
432
+ * child would be re-pointed at the chat default.
433
+ * - An explicitly chosen live model never gets overwritten. We treat the live
434
+ * model as "explicit" when it differs from ALL of: Pi's first scoped model
435
+ * (the value Pi would have picked on its own), Pi's saved default, and our
436
+ * persisted record. Anything explicit (`--model`, another extension, a
437
+ * project default) therefore wins.
438
+ * - When the live model already IS the persisted default, nothing to do.
439
+ * - An explicit PROJECT-level default (`<cwd>/.pi/settings.json`) is never
440
+ * replaced: Pi merges that file over the global one, so the extension agrees
441
+ * with Pi rather than fighting it (user decision).
442
+ */
443
+ export function shouldApplyModelDefaultAtSessionStart(
444
+ input: ModelDefaultApplyInput,
445
+ ): ModelDefaultApplyDecision {
446
+ if (input.reason !== "startup" && input.reason !== "new") {
447
+ return { apply: false, reason: "reason-not-new" };
448
+ }
449
+ if (input.subagentRoleEnv && input.subagentRoleEnv.trim()) {
450
+ return { apply: false, reason: "subagent" };
451
+ }
452
+ const persisted = input.persisted;
453
+ if (!persisted) return { apply: false, reason: "no-persisted" };
454
+ // A project-level default outranks the extension's record inside its project.
455
+ // Checked before the live-model comparisons so a live model that happens to
456
+ // equal `scopedFirst` cannot be mistaken for Pi's own pick.
457
+ if (input.projectDefault) {
458
+ return { apply: false, reason: "project-default" };
459
+ }
460
+ const live = input.live;
461
+ if (!live) return { apply: true, reason: "apply" };
462
+ if (sameModelRef(live, persisted)) {
463
+ return { apply: false, reason: "live-is-persisted" };
464
+ }
465
+ const matchesScopedFirst = sameModelRef(live, input.scopedFirst);
466
+ const matchesSettingsDefault = sameModelRef(live, input.settingsDefault);
467
+ if (matchesScopedFirst || matchesSettingsDefault) {
468
+ return { apply: true, reason: "apply" };
469
+ }
470
+ return { apply: false, reason: "live-is-explicit" };
471
+ }
472
+
473
+ // ── Event gating ────────────────────────────────────────────────────────
474
+
475
+ /**
476
+ * Should a `model_select` event be recorded as the saved default?
477
+ *
478
+ * Only a user gesture counts: `source: "set"` (picker Enter/Ctrl+S, `/model`,
479
+ * Pi Desktop's picker) or `source: "cycle"` (Ctrl+P / Ctrl+Alt). `"restore"`
480
+ * is Pi re-applying a recorded model on resume/reload, and any switch made by
481
+ * the extension itself runs with `suppressed` set.
482
+ *
483
+ * This is the regression lock for "entering Plan/Build must not change the
484
+ * default model": the extension sets `suppressed` around its own switches.
485
+ */
486
+ export function shouldPersistModelSelect(input: {
487
+ source: string | undefined;
488
+ suppressed: boolean;
489
+ }): boolean {
490
+ if (input.suppressed) return false;
491
+ return input.source === "set" || input.source === "cycle";
492
+ }
493
+
494
+ /**
495
+ * Should a `thinking_level_select` event be recorded as the saved default's
496
+ * thinking level?
497
+ *
498
+ * - Never while the extension is switching (mode entry / restore / startup).
499
+ * - Only for the model the default refers to. A role model's thinking belongs
500
+ * to that role, so persisting it would corrupt the next session's default.
501
+ * - When no default is recorded yet, the current model is adopted (a user who
502
+ * changes only the thinking level has made an explicit choice).
503
+ */
504
+ export function shouldPersistThinkingSelect(input: {
505
+ suppressed: boolean;
506
+ current: { provider: string; id: string } | undefined;
507
+ defaultModel: { provider: string; id: string } | undefined;
508
+ }): boolean {
509
+ if (input.suppressed) return false;
510
+ if (!input.current?.provider || !input.current.id) return false;
511
+ if (!input.defaultModel) return true;
512
+ return sameModelRef(input.current, input.defaultModel);
513
+ }
514
+
515
+ /** Short human explanation for a decision (warnings, diagnostics). */
516
+ export function describeModelDefaultApply(
517
+ decision: ModelDefaultApplyDecision,
518
+ ): string {
519
+ switch (decision.reason) {
520
+ case "apply":
521
+ return "applying the saved default";
522
+ case "reason-not-new":
523
+ return "resumed/reloaded session — keeping its recorded model";
524
+ case "subagent":
525
+ return "workflow subagent — keeping its role model";
526
+ case "no-persisted":
527
+ return "no saved default";
528
+ case "project-default":
529
+ return "a project-level default is configured — keeping it";
530
+ case "live-is-persisted":
531
+ return "already on the saved default";
532
+ case "live-is-explicit":
533
+ return "an explicit model was selected — keeping it";
534
+ }
535
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@dieulc/workflow",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
7
7
  "description": "Plan↔Build mode with an independent Review Mode reviewer, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.",
8
8
  "license": "MIT",
9
9
  "scripts": {
10
- "test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts edit-policy.test.ts",
10
+ "test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts",
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json",
12
12
  "check:policy": "node --experimental-transform-types scripts/verify-write-policy.mjs"
13
13
  },
@@ -31,6 +31,12 @@
31
31
  "edit-policy.ts",
32
32
  "roles.ts",
33
33
  "profiles.ts",
34
+ "picker.ts",
35
+ "tab-policy.ts",
36
+ "tab-editor.ts",
37
+ "questionnaire-layout.ts",
38
+ "questionnaire-view.ts",
39
+ "model-default.ts",
34
40
  "permissions.ts",
35
41
  "review.ts",
36
42
  "review-providers.ts",