@wichayutdew/pi-workflows 2.6.0 → 2.7.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.
Files changed (47) hide show
  1. package/README.md +30 -0
  2. package/dist/index.js +141 -17
  3. package/examples/starter-kit/agents/planner.md +4 -0
  4. package/examples/starter-kit/agents/reviewer.md +4 -0
  5. package/examples/starter-kit/agents/scout.md +4 -0
  6. package/examples/starter-kit/agents/worker.md +4 -0
  7. package/examples/starter-kit/agents/workspace-preparer.md +4 -0
  8. package/examples/starter-kit/investigate.workflow.yaml +34 -64
  9. package/examples/starter-kit/jira.workflow.yaml +75 -0
  10. package/examples/starter-kit/mr-comment.workflow.yaml +48 -115
  11. package/examples/starter-kit/mr-review.workflow.yaml +36 -93
  12. package/examples/starter-kit/settings.yaml +2 -1
  13. package/examples/starter-kit/steps/investigate/investigate.md +18 -55
  14. package/examples/starter-kit/steps/investigate/retrieve.md +15 -50
  15. package/examples/starter-kit/steps/investigate/validate.md +10 -36
  16. package/examples/starter-kit/steps/jira/create.md +25 -0
  17. package/examples/starter-kit/steps/jira/draft.md +18 -0
  18. package/examples/starter-kit/steps/jira/plan.md +30 -0
  19. package/examples/starter-kit/steps/mr-comment/checkout-source.md +7 -60
  20. package/examples/starter-kit/steps/mr-comment/fetch.md +11 -36
  21. package/examples/starter-kit/steps/mr-comment/implement.md +10 -34
  22. package/examples/starter-kit/steps/mr-comment/plan.md +47 -70
  23. package/examples/starter-kit/steps/mr-comment/publish.md +11 -32
  24. package/examples/starter-kit/steps/mr-comment/verify.md +8 -42
  25. package/examples/starter-kit/steps/mr-review/fetch.md +8 -46
  26. package/examples/starter-kit/steps/mr-review/publish-approved.md +7 -38
  27. package/examples/starter-kit/steps/mr-review/review-for-approval.md +24 -113
  28. package/examples/starter-kit/steps/mr-review/verify-published.md +9 -30
  29. package/examples/starter-kit/steps/shared/prepare-workspace.md +9 -105
  30. package/examples/starter-kit/steps/shared/publish-remote.md +8 -37
  31. package/examples/starter-kit/steps/ticket/implement.md +11 -58
  32. package/examples/starter-kit/steps/ticket/plan.md +49 -171
  33. package/examples/starter-kit/steps/ticket/verify.md +12 -98
  34. package/examples/starter-kit/steps/work/implement.md +11 -58
  35. package/examples/starter-kit/steps/work/plan.md +42 -130
  36. package/examples/starter-kit/steps/work/verify.md +11 -58
  37. package/examples/starter-kit/ticket.workflow.yaml +38 -81
  38. package/examples/starter-kit/work.workflow.yaml +34 -72
  39. package/package.json +7 -4
  40. package/schemas/workflow.schema.json +61 -0
  41. package/scripts/patch-herdr-agent-state.mjs +36 -0
  42. package/src/config/types.ts +9 -0
  43. package/src/config/validation/step.ts +117 -0
  44. package/src/harness/artifact-contract.ts +46 -0
  45. package/src/harness/gate-submission-action.ts +28 -0
  46. package/src/harness/status-actions.ts +19 -0
  47. package/src/herdr-workflow-state.ts +65 -0
@@ -2,6 +2,7 @@ import { isAbsolute, win32 } from 'node:path';
2
2
  import {
3
3
  MAX_WORKSPACE_ALLOWED_ROOTS,
4
4
  MAX_WORKSPACE_PATH_CHARS,
5
+ type ArtifactContract,
5
6
  type PromptSpec,
6
7
  type StepWorkspaceBinding,
7
8
  type WorkflowGate,
@@ -68,6 +69,106 @@ function parseTransitions(
68
69
  return transitions;
69
70
  }
70
71
 
72
+ function parseArtifactContract(
73
+ value: unknown,
74
+ path: string,
75
+ errors: ValidationErrors,
76
+ ): ArtifactContract | undefined {
77
+ if (value === undefined) return undefined;
78
+ if (!isJsonObject(value)) {
79
+ errors.push(`${path}: expected an object`);
80
+ return undefined;
81
+ }
82
+ rejectUnknownKeys(
83
+ value,
84
+ [
85
+ 'maxChars',
86
+ 'requiredSubstrings',
87
+ 'forbiddenSubstrings',
88
+ 'equalOccurrenceGroups',
89
+ 'onValidationFailure',
90
+ ],
91
+ path,
92
+ errors,
93
+ );
94
+ if (value.maxChars === undefined) {
95
+ errors.push(`${path}.maxChars: expected an integer from 1 to 200000`);
96
+ }
97
+ const maxChars = readInteger(
98
+ value.maxChars,
99
+ 200_000,
100
+ `${path}.maxChars`,
101
+ errors,
102
+ { min: 1, max: 200_000 },
103
+ );
104
+ const parseSubstrings = (field: string): Array<string> => {
105
+ const values = readStringList(
106
+ value[field],
107
+ `${path}.${field}`,
108
+ errors,
109
+ /.+/,
110
+ );
111
+ if (values.length > 32) {
112
+ errors.push(`${path}.${field}: at most 32 values are allowed`);
113
+ }
114
+ values.forEach((substring, index) => {
115
+ if (substring.length > 1_024) {
116
+ errors.push(`${path}.${field}[${index}]: exceeds 1024 characters`);
117
+ }
118
+ });
119
+ return values;
120
+ };
121
+ const equalOccurrenceGroups = (() => {
122
+ if (value.equalOccurrenceGroups === undefined) return [];
123
+ if (!Array.isArray(value.equalOccurrenceGroups)) {
124
+ errors.push(`${path}.equalOccurrenceGroups: expected an array`);
125
+ return [];
126
+ }
127
+ if (value.equalOccurrenceGroups.length > 32) {
128
+ errors.push(
129
+ `${path}.equalOccurrenceGroups: at most 32 groups are allowed`,
130
+ );
131
+ }
132
+ return value.equalOccurrenceGroups.reduce<Array<Array<string>>>(
133
+ (groups, group, index) => {
134
+ const groupPath = `${path}.equalOccurrenceGroups[${index}]`;
135
+ const values = readStringList(group, groupPath, errors, /.+/);
136
+ if (values.length < 2) {
137
+ errors.push(`${groupPath}: at least two values are required`);
138
+ }
139
+ if (values.length > 32) {
140
+ errors.push(`${groupPath}: at most 32 values are allowed`);
141
+ }
142
+ values.forEach((substring, valueIndex) => {
143
+ if (substring.length > 1_024) {
144
+ errors.push(`${groupPath}[${valueIndex}]: exceeds 1024 characters`);
145
+ }
146
+ });
147
+ return [...groups, values];
148
+ },
149
+ [],
150
+ );
151
+ })();
152
+ const onValidationFailure =
153
+ value.onValidationFailure === undefined
154
+ ? undefined
155
+ : readString(
156
+ value.onValidationFailure,
157
+ `${path}.onValidationFailure`,
158
+ errors,
159
+ );
160
+ if (onValidationFailure !== undefined && onValidationFailure !== 'retry') {
161
+ errors.push(`${path}.onValidationFailure: expected retry`);
162
+ }
163
+ return {
164
+ maxChars,
165
+ requiredSubstrings: parseSubstrings('requiredSubstrings'),
166
+ forbiddenSubstrings: parseSubstrings('forbiddenSubstrings'),
167
+ equalOccurrenceGroups,
168
+ ...(onValidationFailure === 'retry' ? { onValidationFailure } : {}),
169
+ };
170
+ }
171
+
71
172
  function parseGate(
72
173
  value: unknown,
73
174
  path: string,
@@ -86,6 +187,7 @@ function parseGate(
86
187
  'approvedOutcome',
87
188
  'rejectedOutcome',
88
189
  'timeoutMs',
190
+ 'artifactContract',
89
191
  ],
90
192
  path,
91
193
  errors,
@@ -120,6 +222,11 @@ function parseGate(
120
222
  errors,
121
223
  { pattern: OUTCOME_PATTERN },
122
224
  );
225
+ const artifactContract = parseArtifactContract(
226
+ value.artifactContract,
227
+ `${path}.artifactContract`,
228
+ errors,
229
+ );
123
230
  if (provider === 'prompt' && value.timeoutMs !== undefined) {
124
231
  errors.push(`${path}.timeoutMs: only valid with provider "plannotator"`);
125
232
  }
@@ -136,12 +243,14 @@ function parseGate(
136
243
  submitOutcome,
137
244
  approvedOutcome,
138
245
  rejectedOutcome,
246
+ ...(artifactContract ? { artifactContract } : {}),
139
247
  }
140
248
  : {
141
249
  provider,
142
250
  submitOutcome,
143
251
  approvedOutcome,
144
252
  rejectedOutcome,
253
+ ...(artifactContract ? { artifactContract } : {}),
145
254
  timeoutMs: readInteger(
146
255
  value.timeoutMs,
147
256
  30_000,
@@ -312,6 +421,14 @@ export function parseWorkflowStep(
312
421
  `${path}.transitions: submitOutcome is handled by the gate and must not be a transition`,
313
422
  );
314
423
  }
424
+ if (
425
+ gate.artifactContract?.onValidationFailure === 'retry' &&
426
+ !Object.hasOwn(transitions, 'retry')
427
+ ) {
428
+ errors.push(
429
+ `${path}.transitions: artifact-contract retry requires a "retry" transition`,
430
+ );
431
+ }
315
432
  }
316
433
  if (workspace) {
317
434
  workspace.bindOn.forEach((outcome) => {
@@ -0,0 +1,46 @@
1
+ import type { ArtifactContract } from '../config/types.ts';
2
+
3
+ function countOccurrences(value: string, substring: string): number {
4
+ let count = 0;
5
+ let offset = 0;
6
+ while (true) {
7
+ const match = value.indexOf(substring, offset);
8
+ if (match === -1) return count;
9
+ count += 1;
10
+ offset = match + substring.length;
11
+ }
12
+ }
13
+
14
+ export function validateArtifactContract(
15
+ artifact: string,
16
+ contract: ArtifactContract | undefined,
17
+ ): string | undefined {
18
+ if (!contract) return undefined;
19
+ if (artifact.length > contract.maxChars) {
20
+ return `gate artifact exceeds ${contract.maxChars} characters`;
21
+ }
22
+ const required = contract.requiredSubstrings.find(
23
+ (substring) => !artifact.includes(substring),
24
+ );
25
+ if (required) {
26
+ return `gate artifact is missing required text: ${JSON.stringify(required)}`;
27
+ }
28
+ const forbidden = contract.forbiddenSubstrings.find((substring) =>
29
+ artifact.includes(substring),
30
+ );
31
+ if (forbidden) {
32
+ return `gate artifact contains forbidden text: ${JSON.stringify(forbidden)}`;
33
+ }
34
+ for (const group of contract.equalOccurrenceGroups) {
35
+ const counts = group.map((substring) =>
36
+ countOccurrences(artifact, substring),
37
+ );
38
+ if (counts.some((count) => count === 0)) {
39
+ return `gate artifact is missing required repeated text: ${JSON.stringify(group)}`;
40
+ }
41
+ if (!counts.every((count) => count === counts[0])) {
42
+ return `gate artifact has unequal repeated text counts: ${JSON.stringify(group)}`;
43
+ }
44
+ }
45
+ return undefined;
46
+ }
@@ -1,7 +1,9 @@
1
1
  import type { LoadedWorkflow } from '../config/types.ts';
2
2
  import type { WorkflowRun } from '../engine/state.ts';
3
+ import { validateArtifactContract } from './artifact-contract.ts';
3
4
  import {
4
5
  attachGateReviewId,
6
+ advanceRun,
5
7
  beginGate,
6
8
  failGate,
7
9
  failRun,
@@ -19,6 +21,7 @@ type HarnessActionContext = Pick<
19
21
  | 'pi'
20
22
  | 'restoreBaselineTools'
21
23
  | 'run'
24
+ | 'settleAfterTransition'
22
25
  | 'sessionEpoch'
23
26
  | 'updateStatus'
24
27
  >;
@@ -63,6 +66,31 @@ async function submitGate(
63
66
  this.dependencies.createRequestId();
64
67
  const step = workflow.definition.steps[originalRun.currentStepId];
65
68
  if (!step?.gate) throw new Error('Current step has no gate');
69
+ const contractError = validateArtifactContract(
70
+ artifact,
71
+ step.gate.artifactContract,
72
+ );
73
+ if (contractError) {
74
+ if (step.gate.artifactContract?.onValidationFailure !== 'retry') {
75
+ throw new Error(contractError);
76
+ }
77
+ const retrySummary = `Artifact contract failed: ${contractError}`;
78
+ this.run = advanceRun(
79
+ workflow,
80
+ originalRun,
81
+ 'retry',
82
+ retrySummary,
83
+ this.dependencies.now(),
84
+ );
85
+ this.persist();
86
+ this.updateStatus();
87
+ this.settleAfterTransition(workflow, {
88
+ stepId: originalRun.currentStepId,
89
+ outcome: 'retry',
90
+ summary: retrySummary,
91
+ });
92
+ return;
93
+ }
66
94
 
67
95
  this.run = beginGate(
68
96
  workflow,
@@ -78,6 +78,25 @@ function workflowStatusSnapshot(
78
78
 
79
79
  function updateStatus(this: StatusContext): void {
80
80
  refreshStatusWhileRunning.call(this);
81
+ const run = this.run;
82
+ this.pi.events.emit('pi-workflows:state', {
83
+ state:
84
+ run?.status === 'running'
85
+ ? 'working'
86
+ : run?.status === 'awaiting-gate' || run?.status === 'paused'
87
+ ? 'blocked'
88
+ : run?.status === 'completed'
89
+ ? 'completed'
90
+ : 'interrupted',
91
+ workflowId: run?.workflowId,
92
+ stepId: run?.currentStepId,
93
+ message:
94
+ run?.status === 'completed'
95
+ ? `Workflow "${run.workflowId}" completed`
96
+ : run?.status === 'paused'
97
+ ? run.pauseReason
98
+ : undefined,
99
+ });
81
100
  if (!this.latestContext) return;
82
101
  if (this.legacyProgressWidgetContext !== this.latestContext) {
83
102
  this.latestContext.ui.setWidget(LEGACY_PROGRESS_WIDGET_KEY, undefined);
@@ -0,0 +1,65 @@
1
+ import net from 'node:net';
2
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
+
4
+ const source = 'herdr:pi-workflows';
5
+
6
+ type WorkflowState = {
7
+ state: 'working' | 'blocked' | 'completed' | 'interrupted';
8
+ workflowId?: string;
9
+ stepId?: string;
10
+ message?: string;
11
+ };
12
+
13
+ export default function herdrWorkflowState(pi: ExtensionAPI): void {
14
+ const socketPath = process.env.HERDR_SOCKET_PATH;
15
+ const paneId = process.env.HERDR_PANE_ID;
16
+ if (process.env.HERDR_ENV !== '1' || !socketPath || !paneId) return;
17
+
18
+ let seq = Date.now() * 1000;
19
+ let last = '';
20
+ pi.events.on('pi-workflows:state', (data: unknown) => {
21
+ if (!data || typeof data !== 'object') return;
22
+ const event = data as WorkflowState;
23
+ if (
24
+ !['working', 'blocked', 'completed', 'interrupted'].includes(event.state)
25
+ )
26
+ return;
27
+ const state =
28
+ event.state === 'working'
29
+ ? 'working'
30
+ : event.state === 'blocked'
31
+ ? 'blocked'
32
+ : 'idle';
33
+ const message =
34
+ event.message ??
35
+ (event.workflowId
36
+ ? `Workflow "${event.workflowId}" ${event.state}`
37
+ : undefined);
38
+ const fingerprint = `${state}:${message ?? ''}`;
39
+ if (fingerprint === last) return;
40
+ last = fingerprint;
41
+ const request = {
42
+ id: `${source}:${Date.now()}:${++seq}`,
43
+ method: 'pane.report_agent',
44
+ params: {
45
+ pane_id: paneId,
46
+ source,
47
+ agent: 'pi-workflows',
48
+ state,
49
+ message,
50
+ seq,
51
+ },
52
+ };
53
+ const socket = net.createConnection(socketPath);
54
+ const timeout = setTimeout(() => socket.destroy(), 1500);
55
+ timeout.unref();
56
+ socket.on('connect', () => socket.write(`${JSON.stringify(request)}\n`));
57
+ socket.on('data', () => socket.destroy());
58
+ socket.on('close', () => {
59
+ clearTimeout(timeout);
60
+ });
61
+ socket.on('error', () => {
62
+ clearTimeout(timeout);
63
+ });
64
+ });
65
+ }