@brainervirus/workit-core 1.3.0 → 1.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -711,7 +711,7 @@ export const branchSetup = ({
711
711
  if (classifyBranchDirt(cwd) === "stash-required" && stash !== "yes") {
712
712
  return {
713
713
  error:
714
- "dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
714
+ "dirty working tree — obtain native host approval before stashing and setting up the branch",
715
715
  phase: "preflight",
716
716
  };
717
717
  }
@@ -56,7 +56,7 @@ type WithExternalActionLock = <T>(
56
56
  operation: () => Promise<ExternalActionResult<T>>,
57
57
  ) => Promise<ExternalActionResult<T>>;
58
58
 
59
- const externalActionSchema = z.discriminatedUnion("operation", [
59
+ export const externalActionSchema = z.discriminatedUnion("operation", [
60
60
  z
61
61
  .object({
62
62
  operation: z.literal("git.branch_setup"),
@@ -209,6 +209,13 @@ const externalActionSchema = z.discriminatedUnion("operation", [
209
209
  ]);
210
210
  export type ExternalActionRequest = z.infer<typeof externalActionSchema>;
211
211
 
212
+ /** The complete external-action contract for hosts that register native tools. */
213
+ export const externalActionJsonSchema = (): z.core.JSONSchema.BaseSchema =>
214
+ ({
215
+ type: "object",
216
+ ...z.toJSONSchema(externalActionSchema, { target: "draft-2020-12" }),
217
+ }) as z.core.JSONSchema.BaseSchema;
218
+
212
219
  export type ExternalActionOperation = ExternalActionRequest["operation"];
213
220
 
214
221
  export const externalActionRequest = (value: unknown): Result<ExternalActionRequest> => {
@@ -95,6 +95,15 @@ need zero Workit task, assessment, or writer calls. Use one compact tracked
95
95
  record only when handoff, dependent steps, concurrent actors, or meaningful
96
96
  decisions make continuity useful; similar titles alone never merge tasks.
97
97
 
98
+ For a routine user-authorized branch or commit, prefer native host Git/shell
99
+ tools from the outset when managed coordination or outcome reconciliation is
100
+ not needed. Inspect the actual target checkout and its configured conventions;
101
+ use Workit's read-only context/policy tools when needed. Do not start a task,
102
+ acquire a writer, or mint a decision receipt solely for a native Git action.
103
+ Native permissions still apply. Never switch execution paths to evade a denial
104
+ or repeat an uncertain managed effect. A local-commit endpoint does not imply
105
+ PR readiness, a fresh review, or post-commit assessment/closure paperwork.
106
+
98
107
  For tracked work, inspect its current state and use the shared operations for
99
108
  task, policy, evidence, finding, decision, worker, writer, and state changes.
100
109
  Start a record once for an explicit tracked objective; assess or reassess only
@@ -116,6 +125,11 @@ Preserve uncertain external outcomes and reconcile repository/provider state
116
125
  before retrying. Internal reservations prevent duplicate or ambiguous effects;
117
126
  they are not permission tickets for every edit.
118
127
 
128
+ On OpenCode V1 and V2, use native host tools for external mutations. Workit
129
+ provides read-only workit_context; it has no managed external-action executor.
130
+ Do not create proposals or request Workit approvals merely to invoke native
131
+ tools. Old uncertain managed effects still require evidence before any retry.
132
+
119
133
  Use host-native authorization through the host's supported path. A native
120
134
  question receipt records an actual question interaction; it is not automatic
121
135
  host permission. Record a meaningful user choice once with provenance when
@@ -326,6 +326,11 @@ function resolveRequirements(input: NormalizedResolverInput): Requirement[] {
326
326
  }),
327
327
  );
328
328
 
329
+ const independentBehaviorReview =
330
+ input.preferences?.thorough === true ||
331
+ input.assessment.consequences.some((consequence) =>
332
+ ["security", "data", "public_contract", "operations"].includes(consequence.area),
333
+ );
329
334
  if (signals.behaviorChange.value === true)
330
335
  requirements.push(
331
336
  requirement({
@@ -340,15 +345,18 @@ function resolveRequirements(input: NormalizedResolverInput): Requirement[] {
340
345
  acceptanceAllowed: false,
341
346
  }),
342
347
  requirement({
343
- ruleId: "fresh-context-review",
348
+ ruleId: independentBehaviorReview ? "fresh-context-review" : "self-review",
344
349
  dimension: "review",
345
350
  scope,
346
- reason: "A non-mechanical behavior change requires fresh-context review.",
347
- satisfaction:
348
- "A separate review context examines the current candidate and records its result.",
351
+ reason: independentBehaviorReview
352
+ ? "Consequential behavior or the thorough preference requires fresh-context review."
353
+ : "Bounded behavior changes use self-review unless consequential risks or trusted rules require more.",
354
+ satisfaction: independentBehaviorReview
355
+ ? "A separate review context examines the current candidate and records its result."
356
+ : "The lead reviews the resulting diff and records the relevant scope and checks.",
349
357
  before: "close",
350
358
  dependentAction: null,
351
- acceptanceAllowed: false,
359
+ acceptanceAllowed: !independentBehaviorReview,
352
360
  }),
353
361
  );
354
362
  else if (signals.mechanicalLowRisk.value === true)
@@ -363,7 +363,7 @@ const CHANGELOG_RULES = `- Use an [Unreleased] section.
363
363
  - Entries should be human-readable and user-facing.
364
364
  - Do not use raw commit messages as changelog bullets.
365
365
  - MERGE into existing ### Category under [Unreleased] — never append a second ### Added / ### Fixed block.
366
- - Apply with the native workit_changelog_apply tool only (not hand-edits under Unreleased).
366
+ - Apply with the host-native editor, preserving the repository changelog conventions.
367
367
  - If Unreleased already has duplicate category headings, normalize_only first.`;
368
368
 
369
369
  /** Port of changelog-context.sh — changelog update context. */
@@ -1140,8 +1140,61 @@ export const OPERATION_SCHEMA_DEPTH = 1;
1140
1140
  /** Provider nesting limit every advertised Workit schema must stay within. */
1141
1141
  export const OPERATION_SCHEMA_MAX_DEPTH = 10;
1142
1142
 
1143
- export const canonicalFieldsDescription = (fields: string[]): string =>
1144
- `Canonical object fields: ${fields.join(", ")}. Workit validates the complete nested value.`;
1143
+ const schemaShapeDescription = (value: unknown, depth = 0): string => {
1144
+ if (!value || typeof value !== "object") return "value";
1145
+ const schema = value as Record<string, unknown>;
1146
+ if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) {
1147
+ const options = (Array.isArray(schema.oneOf) ? schema.oneOf : schema.anyOf) as unknown[];
1148
+ return `(${options.map((option) => schemaShapeDescription(option, depth)).join("|")})`;
1149
+ }
1150
+ if (schema.const !== undefined) return `=${JSON.stringify(schema.const)}`;
1151
+ if (Array.isArray(schema.enum)) return `[${schema.enum.map(String).join("|")}]`;
1152
+ const type = Array.isArray(schema.type) ? schema.type.join("/") : schema.type;
1153
+ if (type === "array") return `arr<${schemaShapeDescription(schema.items, depth)}>`;
1154
+ if (type === "object" && schema.properties && typeof schema.properties === "object") {
1155
+ if (depth >= 2) return "obj";
1156
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
1157
+ const properties = schema.properties as Record<string, unknown>;
1158
+ const fields = Object.entries(properties).map(
1159
+ ([name, child]) =>
1160
+ `${name}${required.has(name) ? "!" : ""}:${schemaShapeDescription(child, depth + 1)}`,
1161
+ );
1162
+ return `obj{${fields.join(",")}}`;
1163
+ }
1164
+ if (typeof type !== "string") return "?";
1165
+ return type
1166
+ .split("/")
1167
+ .map((part) => ({ string: "str", boolean: "bool", number: "num", null: "null" })[part] ?? part)
1168
+ .join("|");
1169
+ };
1170
+
1171
+ export const canonicalFieldsDescription = (
1172
+ value: string[] | Record<string, unknown> | z.ZodType,
1173
+ ): string => {
1174
+ if (Array.isArray(value))
1175
+ return `Canonical object fields: ${value.join(", ")}. Workit validates the complete nested value.`;
1176
+ const isZodType = (schema: Record<string, unknown> | z.ZodType): schema is z.ZodType =>
1177
+ "_zod" in schema && typeof schema._zod === "object";
1178
+ const schema = isZodType(value)
1179
+ ? (z.toJSONSchema(value, { target: "draft-2020-12" }) as Record<string, unknown>)
1180
+ : value;
1181
+ if (!schema.properties || typeof schema.properties !== "object") {
1182
+ const variants = Array.isArray(schema.oneOf)
1183
+ ? schema.oneOf
1184
+ : Array.isArray(schema.anyOf)
1185
+ ? schema.anyOf
1186
+ : null;
1187
+ return variants
1188
+ ? `Canonical shapes: ${variants.map((variant) => schemaShapeDescription(variant)).join(" | ")}. Full nested value is validated.`
1189
+ : "Canonical nested value; Workit validates the complete value.";
1190
+ }
1191
+ const properties = schema.properties as Record<string, unknown>;
1192
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
1193
+ const fields = Object.entries(properties).map(
1194
+ ([name, child]) => `${name}${required.has(name) ? "!" : ""}:${schemaShapeDescription(child)}`,
1195
+ );
1196
+ return `Fields (! required): ${fields.join(", ")}. Full nested value is validated.`;
1197
+ };
1145
1198
 
1146
1199
  /**
1147
1200
  * Provider-safe projection of an operation JSON schema. Objects deeper than
@@ -1178,11 +1231,12 @@ export function boundedOperationJsonSchema(
1178
1231
  if (typeof node !== "object" || node === null) return node;
1179
1232
  const record = node as Record<string, unknown>;
1180
1233
  if (record.properties && typeof record.properties === "object") {
1181
- const fields = Object.keys(record.properties as Record<string, unknown>);
1234
+ const properties = record.properties as Record<string, unknown>;
1235
+ const fields = Object.keys(properties);
1182
1236
  if (currentDepth >= maxDepth || fields.length === 0) {
1183
1237
  const description = stringifiedObjects
1184
- ? `${canonicalFieldsDescription(fields)} A JSON-encoded string is also accepted.`
1185
- : canonicalFieldsDescription(fields);
1238
+ ? `${canonicalFieldsDescription(record)} A JSON-encoded string is also accepted.`
1239
+ : canonicalFieldsDescription(record);
1186
1240
  return tolerate({ type: "object", description });
1187
1241
  }
1188
1242
  return tolerate({
@@ -409,6 +409,43 @@ const canonicalGlob = (glob: string): string => {
409
409
  return real + (prefix.endsWith("/") ? "/" : "") + rest;
410
410
  };
411
411
 
412
+ const globSpecificity = (glob: string): number =>
413
+ glob
414
+ .replaceAll("\\", "/")
415
+ .split("/")
416
+ .filter((segment) => segment.length > 0 && !/[*?[\]{]/.test(segment)).length;
417
+
418
+ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
419
+ matches: T[],
420
+ cwd: string,
421
+ workspaceName?: string,
422
+ ): T | undefined => {
423
+ if (workspaceName !== undefined) {
424
+ const named = matches.filter((entry) => entry.name === workspaceName);
425
+ if (named.length === 1) return named[0];
426
+ if (named.length > 1)
427
+ throw new Error(`ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`);
428
+ if (matches.length > 0) {
429
+ throw new Error(
430
+ `workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matches.map((entry) => entry.name).join(", ")}`,
431
+ );
432
+ }
433
+ throw new Error(`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`);
434
+ }
435
+
436
+ if (matches.length < 2) return matches[0];
437
+ const highestSpecificity = Math.max(...matches.map((entry) => globSpecificity(entry.glob)));
438
+ const mostSpecific = matches.filter(
439
+ (entry) => globSpecificity(entry.glob) === highestSpecificity,
440
+ );
441
+ if (mostSpecific.length > 1) {
442
+ throw new Error(
443
+ `ambiguous workspace for ${cwd}; choose one of: ${mostSpecific.map((entry) => entry.name).join(", ")}`,
444
+ );
445
+ }
446
+ return mostSpecific[0];
447
+ };
448
+
412
449
  /** Match a cwd against the workspaces.json under an explicit config dir. */
413
450
  export const resolveWorkspaceFrom = (
414
451
  cwd: string,
@@ -440,23 +477,7 @@ export const resolveWorkspaceFrom = (
440
477
  }
441
478
  if (matched) matches.push(ws);
442
479
  }
443
- if (workspaceName !== undefined) {
444
- const selected = matches.find((ws) => ws.name === workspaceName);
445
- if (!selected && matches.length > 0) {
446
- const matchingNames = matches.map((ws) => ws.name);
447
- throw new Error(
448
- `workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matchingNames.join(", ")}`,
449
- );
450
- }
451
- if (selected) return selected;
452
- throw new Error(`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`);
453
- }
454
- if (matches.length > 1) {
455
- throw new Error(
456
- `ambiguous workspace for ${cwd}; choose one of: ${matches.map((ws) => ws.name).join(", ")}`,
457
- );
458
- }
459
- return matches[0] ?? null;
480
+ return selectWorkspaceMatch(matches, cwd, workspaceName) ?? null;
460
481
  };
461
482
 
462
483
  export const resolveWorkspace = (
@@ -560,22 +581,7 @@ const resolveRuntimeWorkspaceCandidate = (
560
581
  );
561
582
  });
562
583
  const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
563
- let selected: RuntimeWorkspaceCandidate | undefined;
564
- if (workspaceName) {
565
- const named = matches.filter((entry) => entry.name === workspaceName);
566
- if (named.length === 1) selected = named[0];
567
- else if (named.length > 1)
568
- throw new Error(`ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`);
569
- else if (matches.length)
570
- throw new Error(
571
- `workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matches.map((entry) => entry.name).join(", ")}`,
572
- );
573
- else throw new Error(`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`);
574
- } else if (matches.length > 1)
575
- throw new Error(
576
- `ambiguous workspace for ${cwd}; choose one of: ${matches.map((entry) => entry.name).join(", ")}`,
577
- );
578
- else selected = matches[0];
584
+ const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
579
585
  if (!selected) return null;
580
586
  const policy = runtimeWorkspacePolicySchema(kind).safeParse(selected);
581
587
  if (!policy.success) {
@@ -103,7 +103,7 @@ type PostData = {
103
103
  loggedMinutes: number;
104
104
  outcome?: "unknown" | "not_applied";
105
105
  instructions?: string;
106
- retry?: "workit_youtrack_post" | "workit_youtrack_log_time";
106
+ retry?: "youtrack.update" | "youtrack.time";
107
107
  };
108
108
 
109
109
  const notApplied = (value: LegacyValue): value is NotApplied =>
@@ -130,7 +130,7 @@ export async function postUpdate(
130
130
  postedComment: false,
131
131
  loggedMinutes: 0,
132
132
  outcome: "not_applied",
133
- retry: "workit_youtrack_post",
133
+ retry: "youtrack.update",
134
134
  });
135
135
  unwrap(comment);
136
136
  } catch (error) {
@@ -158,7 +158,7 @@ export async function postUpdate(
158
158
  postedComment: true,
159
159
  loggedMinutes: 0,
160
160
  outcome: "not_applied",
161
- retry: "workit_youtrack_log_time",
161
+ retry: "youtrack.time",
162
162
  });
163
163
  unwrap(time);
164
164
  } catch (error) {
@@ -191,7 +191,7 @@ export async function logTimeUpdate(
191
191
  issueId: input.issueId,
192
192
  loggedMinutes: 0,
193
193
  outcome: "not_applied",
194
- retry: "workit_youtrack_log_time",
194
+ retry: "youtrack.time",
195
195
  });
196
196
  return ok(unwrap(value));
197
197
  } catch (error) {
@@ -846,7 +846,7 @@ export async function postUpdate(
846
846
  postedComment: true,
847
847
  loggedMinutes: 0,
848
848
  error: time.error,
849
- retry: "workit_youtrack_log_time",
849
+ retry: "youtrack.time",
850
850
  };
851
851
  }
852
852
  return { ok: true, issueId, postedComment: true, loggedMinutes: minutes };
package/src/core.ts CHANGED
@@ -124,6 +124,8 @@ export {
124
124
  externalActionRef,
125
125
  externalActionDescriptor,
126
126
  externalActionHelp,
127
+ externalActionJsonSchema,
128
+ externalActionSchema,
127
129
  planReservationLength,
128
130
  externalActionRequest,
129
131
  matchesNativeExternalAction,