@exaudeus/workrail 0.14.0 → 0.16.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.
@@ -550,8 +550,8 @@
550
550
  "bytes": 1748
551
551
  },
552
552
  "mcp/handlers/workflow.js": {
553
- "sha256": "99ff7aac9705ef540cc5681515cf2aa9650d886761f7ae69f829ff87ac772756",
554
- "bytes": 8246
553
+ "sha256": "7ebd383922c6f238c35c376c8a0aa87b0969a80bb4e7c48e704a8480fd974bb4",
554
+ "bytes": 8254
555
555
  },
556
556
  "mcp/index.d.ts": {
557
557
  "sha256": "525b4247cf90ba3af66769462bcfaab5dbf38ee8c49d2a9ceec1e4b38e33511b",
@@ -574,8 +574,8 @@
574
574
  "bytes": 168
575
575
  },
576
576
  "mcp/server.js": {
577
- "sha256": "41c61033d821f9794cada0bb40058fa220fa11175897326ae597df422a1fcd0b",
578
- "bytes": 13552
577
+ "sha256": "cf128711a6d74bb604653431c9e3bac4fac9fed94bd4da8326438e86809501e3",
578
+ "bytes": 13866
579
579
  },
580
580
  "mcp/tool-description-provider.d.ts": {
581
581
  "sha256": "1d46abc3112e11b68e57197e846f5708293ec9b2281fa71a9124ee2aad71e41b",
@@ -590,8 +590,8 @@
590
590
  "bytes": 132
591
591
  },
592
592
  "mcp/tool-descriptions.js": {
593
- "sha256": "06a6eff077301d66a90b6240fbebae166ddfdcd3aa9177d5cfb48d6e0a31698c",
594
- "bytes": 7792
593
+ "sha256": "c8150119782dadd286e8430e9377b0b727891c414be2396e314423713939d6eb",
594
+ "bytes": 7980
595
595
  },
596
596
  "mcp/tool-factory.d.ts": {
597
597
  "sha256": "0fe3c6b863b2d7aef0c3d659ff54f3a9ee8a0a3c2005b6565d2f8ad517bc7211",
@@ -602,12 +602,12 @@
602
602
  "bytes": 479
603
603
  },
604
604
  "mcp/tools.d.ts": {
605
- "sha256": "f11991cdc3cdbd2912c38252541818adaca8a99c08cc7c1aaa37636ae112cfd6",
606
- "bytes": 5952
605
+ "sha256": "8474e810cae37197d5968be4c3dfb9751ba2b09fe8a7f39e0e7dcc414af4bdb5",
606
+ "bytes": 5976
607
607
  },
608
608
  "mcp/tools.js": {
609
- "sha256": "34522d0b078477627fe068130d3b0a5d4d8cc9fc90599a583d979080d632f544",
610
- "bytes": 7688
609
+ "sha256": "0af59932b32bad5ebc9cbc925279d325350c91b43085561d0d218035250b641a",
610
+ "bytes": 8020
611
611
  },
612
612
  "mcp/types.d.ts": {
613
613
  "sha256": "4ab4a4af1eeedf9ba9bcdc70476a5adcc24ce05b3d7d715d70979052b1eb7246",
@@ -638,8 +638,8 @@
638
638
  "bytes": 2579
639
639
  },
640
640
  "mcp/v2/tools.js": {
641
- "sha256": "9e934ea8a5f83bffc68d343970292267b207f67d72a5aa824348967e53777820",
642
- "bytes": 2333
641
+ "sha256": "4b0d5d1c019d3f747b0f4211a606d1aba4944f9e570ae9fecc6831987a6cc16f",
642
+ "bytes": 2537
643
643
  },
644
644
  "mcp/validation/bounded-json.d.ts": {
645
645
  "sha256": "82203ac6123d5c6989606c3b5405aaea99ab829c8958835f9ae3ba45b8bc8fd5",
@@ -649,6 +649,54 @@
649
649
  "sha256": "0134fd92e1b160f1b57230d9f8a471044858af43484206f911619cf7159e3f0d",
650
650
  "bytes": 834
651
651
  },
652
+ "mcp/validation/index.d.ts": {
653
+ "sha256": "3e3f12357fd8214470d111454e4002338e5eb93329b5a3758664db51e44c12ec",
654
+ "bytes": 944
655
+ },
656
+ "mcp/validation/index.js": {
657
+ "sha256": "dccd3a2dc7e486afd27ee44f77303486f60cc840563821b97ac341f9cad6650c",
658
+ "bytes": 4445
659
+ },
660
+ "mcp/validation/schema-introspection.d.ts": {
661
+ "sha256": "7e0262e76234dd37079156027e95a30987b8949351f3e9ec0fd7b2be093a159d",
662
+ "bytes": 713
663
+ },
664
+ "mcp/validation/schema-introspection.js": {
665
+ "sha256": "850c09a3c01a5f22440ebc34236c393f3b428748210f5277258a9905cb847d71",
666
+ "bytes": 5293
667
+ },
668
+ "mcp/validation/string-similarity.d.ts": {
669
+ "sha256": "4326210a768a526336b54d4ea20a128a939d92f53e8b2a5a33da06b5372d196a",
670
+ "bytes": 671
671
+ },
672
+ "mcp/validation/string-similarity.js": {
673
+ "sha256": "fafdb80673ad56336009e562cd5dccd93486dd94fa78acbb923cdc47ba63becf",
674
+ "bytes": 2627
675
+ },
676
+ "mcp/validation/suggestion-config.d.ts": {
677
+ "sha256": "70b8395db74ec18bb1ef2309dd16516345b75839d2793bf9c5bfbd1e1d1baa0e",
678
+ "bytes": 388
679
+ },
680
+ "mcp/validation/suggestion-config.js": {
681
+ "sha256": "efda43e48812979d0ddae2abe23809b6b5a3e5b955e74ca5a67716933e468db4",
682
+ "bytes": 592
683
+ },
684
+ "mcp/validation/suggestion-generator.d.ts": {
685
+ "sha256": "491d983f4a03516fc0ba09ff40da2c859ec600f29a41093b0359ba549c7882cc",
686
+ "bytes": 450
687
+ },
688
+ "mcp/validation/suggestion-generator.js": {
689
+ "sha256": "e952a4d3cb569222cde1bd01dd9d5be887ee394ba007478bbb446fa177172859",
690
+ "bytes": 4075
691
+ },
692
+ "mcp/validation/suggestion-types.d.ts": {
693
+ "sha256": "b93ae2e42f4b24789dcbe19db31a41af9534ad0dca85635339c2a10db42e298b",
694
+ "bytes": 1333
695
+ },
696
+ "mcp/validation/suggestion-types.js": {
697
+ "sha256": "c7753960a199508a8a59f8030c4240a076857a3e5926efadc01e808f08d7ff3a",
698
+ "bytes": 729
699
+ },
652
700
  "mcp/validation/workflow-next-prevalidate.d.ts": {
653
701
  "sha256": "179058225dfb17f4be02d6105bbacdaa99f1441cfc25062b38d8283f0bf35b5a",
654
702
  "bytes": 254
@@ -65,7 +65,7 @@ async function handleWorkflowGet(input, ctx) {
65
65
  try {
66
66
  const { createGetWorkflow } = await Promise.resolve().then(() => __importStar(require('../../application/use-cases/get-workflow.js')));
67
67
  const getWorkflowUseCase = createGetWorkflow(ctx.workflowService);
68
- const result = await withTimeout(getWorkflowUseCase(input.id, input.mode), TIMEOUT_MS, 'workflow_get');
68
+ const result = await withTimeout(getWorkflowUseCase(input.workflowId, input.mode), TIMEOUT_MS, 'workflow_get');
69
69
  if (result.isErr()) {
70
70
  const mapped = (0, error_mapper_js_1.mapDomainErrorToToolError)(result.error);
71
71
  return mapped;
@@ -42,6 +42,7 @@ const types_js_1 = require("./types.js");
42
42
  const tool_factory_js_1 = require("./tool-factory.js");
43
43
  const workflow_next_prevalidate_js_1 = require("./validation/workflow-next-prevalidate.js");
44
44
  const bounded_json_js_1 = require("./validation/bounded-json.js");
45
+ const index_js_1 = require("./validation/index.js");
45
46
  const tools_js_1 = require("./tools.js");
46
47
  const tool_registry_js_1 = require("./v2/tool-registry.js");
47
48
  const workflow_js_1 = require("./handlers/workflow.js");
@@ -133,11 +134,14 @@ function createHandler(schema, handler) {
133
134
  return async (args, ctx) => {
134
135
  const parseResult = schema.safeParse(args);
135
136
  if (!parseResult.success) {
137
+ const suggestionResult = (0, index_js_1.generateSuggestions)(args, schema, index_js_1.DEFAULT_SUGGESTION_CONFIG);
138
+ const suggestionDetails = (0, index_js_1.formatSuggestionDetails)(suggestionResult);
136
139
  return toMcpResult((0, types_js_1.errNotRetryable)('VALIDATION_ERROR', 'Invalid input', {
137
140
  validationErrors: parseResult.error.errors.map(e => ({
138
141
  path: e.path.join('.'),
139
142
  message: e.message,
140
143
  })),
144
+ ...suggestionDetails,
141
145
  }));
142
146
  }
143
147
  return toMcpResult(await handler(parseResult.data, ctx));
@@ -10,7 +10,11 @@ Your process:
10
10
  2. Analyze the returned descriptions to find a match for the user's goal.
11
11
  3. If a good match is found, suggest it to the user and use preview_workflow to start.
12
12
  4. If NO match is found, inform the user and then attempt to solve the task using your general abilities.`,
13
- preview_workflow: `Retrieves workflow information with configurable detail level. Supports progressive disclosure to prevent "workflow spoiling" while providing necessary context for workflow selection and initiation.`,
13
+ preview_workflow: `Retrieves workflow information with configurable detail level. Supports progressive disclosure to prevent "workflow spoiling" while providing necessary context for workflow selection and initiation.
14
+
15
+ Parameters:
16
+ - workflowId: The unique identifier of the workflow to retrieve
17
+ - mode (optional): 'metadata' for overview only, 'preview' (default) for first step`,
14
18
  advance_workflow: `Executes one workflow step at a time by returning the next eligible step and an updated execution state.
15
19
 
16
20
  Inputs:
@@ -75,7 +79,9 @@ By retrieving a workflow, you agree to:
75
79
 
76
80
  The workflow content is the user's will expressed as structured steps. Treat each step as a direct instruction from the user.
77
81
 
78
- Returns: Workflow metadata and first step. Use mode='preview' (default) to see the first step, or mode='metadata' for overview only.`,
82
+ Parameters:
83
+ - workflowId: The unique identifier of the workflow to retrieve
84
+ - mode (optional): 'metadata' for overview only, 'preview' (default) for first step`,
79
85
  advance_workflow: `Get your next MANDATORY INSTRUCTION from the active workflow.
80
86
 
81
87
  The step returned is a DIRECT INSTRUCTION from the user. You MUST:
@@ -3,13 +3,13 @@ export type { ToolAnnotations, ToolDefinition } from './tool-factory.js';
3
3
  export declare const WorkflowListInput: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
4
4
  export type WorkflowListInput = z.infer<typeof WorkflowListInput>;
5
5
  export declare const WorkflowGetInput: z.ZodObject<{
6
- id: z.ZodString;
6
+ workflowId: z.ZodString;
7
7
  mode: z.ZodDefault<z.ZodEnum<["metadata", "preview"]>>;
8
8
  }, "strip", z.ZodTypeAny, {
9
- id: string;
9
+ workflowId: string;
10
10
  mode: "metadata" | "preview";
11
11
  }, {
12
- id: string;
12
+ workflowId: string;
13
13
  mode?: "metadata" | "preview" | undefined;
14
14
  }>;
15
15
  export type WorkflowGetInput = z.infer<typeof WorkflowGetInput>;
package/dist/mcp/tools.js CHANGED
@@ -6,9 +6,9 @@ const state_js_1 = require("../domain/execution/state.js");
6
6
  const event_js_1 = require("../domain/execution/event.js");
7
7
  exports.WorkflowListInput = zod_1.z.object({});
8
8
  exports.WorkflowGetInput = zod_1.z.object({
9
- id: zod_1.z
9
+ workflowId: zod_1.z
10
10
  .string()
11
- .regex(/^[A-Za-z0-9_-]+$/, 'ID must contain only letters, numbers, hyphens, and underscores')
11
+ .regex(/^[A-Za-z0-9_-]+$/, 'Workflow ID must contain only letters, numbers, hyphens, and underscores')
12
12
  .describe('The unique identifier of the workflow to retrieve'),
13
13
  mode: zod_1.z
14
14
  .enum(['metadata', 'preview'])
@@ -97,6 +97,7 @@ exports.WORKFLOW_TOOL_TITLES = {
97
97
  exports.CreateSessionInput = zod_1.z.object({
98
98
  workflowId: zod_1.z
99
99
  .string()
100
+ .regex(/^[A-Za-z0-9_-]+$/, 'Workflow ID must contain only letters, numbers, hyphens, and underscores')
100
101
  .describe('Workflow identifier (e.g., "bug-investigation", "mr-review")'),
101
102
  sessionId: zod_1.z
102
103
  .string()
@@ -107,14 +108,14 @@ exports.CreateSessionInput = zod_1.z.object({
107
108
  .describe('Initial session data. Can include dashboard, phases, etc.'),
108
109
  });
109
110
  exports.UpdateSessionInput = zod_1.z.object({
110
- workflowId: zod_1.z.string().describe('Workflow identifier'),
111
+ workflowId: zod_1.z.string().regex(/^[A-Za-z0-9_-]+$/, 'Workflow ID must contain only letters, numbers, hyphens, and underscores').describe('Workflow identifier'),
111
112
  sessionId: zod_1.z.string().describe('Session identifier'),
112
113
  updates: zod_1.z
113
114
  .record(zod_1.z.unknown())
114
115
  .describe('Data to merge into session. Supports nested updates via dot notation.'),
115
116
  });
116
117
  exports.ReadSessionInput = zod_1.z.object({
117
- workflowId: zod_1.z.string().describe('Workflow identifier'),
118
+ workflowId: zod_1.z.string().regex(/^[A-Za-z0-9_-]+$/, 'Workflow ID must contain only letters, numbers, hyphens, and underscores').describe('Workflow identifier'),
118
119
  sessionId: zod_1.z.string().describe('Session identifier'),
119
120
  path: zod_1.z
120
121
  .string()
@@ -4,11 +4,11 @@ exports.V2_TOOL_ANNOTATIONS = exports.V2_TOOL_TITLES = exports.V2ContinueWorkflo
4
4
  const zod_1 = require("zod");
5
5
  exports.V2ListWorkflowsInput = zod_1.z.object({});
6
6
  exports.V2InspectWorkflowInput = zod_1.z.object({
7
- workflowId: zod_1.z.string().min(1).describe('The workflow ID to inspect'),
7
+ workflowId: zod_1.z.string().min(1).regex(/^[A-Za-z0-9_-]+$/, 'Workflow ID must contain only letters, numbers, hyphens, and underscores').describe('The workflow ID to inspect'),
8
8
  mode: zod_1.z.enum(['metadata', 'preview']).default('preview').describe('Detail level'),
9
9
  });
10
10
  exports.V2StartWorkflowInput = zod_1.z.object({
11
- workflowId: zod_1.z.string().min(1).describe('The workflow ID to start'),
11
+ workflowId: zod_1.z.string().min(1).regex(/^[A-Za-z0-9_-]+$/, 'Workflow ID must contain only letters, numbers, hyphens, and underscores').describe('The workflow ID to start'),
12
12
  context: zod_1.z.record(zod_1.z.unknown()).optional().describe('External context inputs (conditions, parameters). Do not include workflow progress state.'),
13
13
  });
14
14
  exports.V2ContinueWorkflowInput = zod_1.z.object({
@@ -0,0 +1,7 @@
1
+ export type { Similarity, ValidationSuggestion, UnknownKeySuggestion, MissingRequiredSuggestion, InvalidEnumSuggestion, SuggestionResult, } from './suggestion-types.js';
2
+ export { similarity, EMPTY_SUGGESTION_RESULT, isUnknownKeySuggestion, isMissingRequiredSuggestion, isInvalidEnumSuggestion, } from './suggestion-types.js';
3
+ export type { SuggestionConfig } from './suggestion-config.js';
4
+ export { DEFAULT_SUGGESTION_CONFIG, MINIMAL_SUGGESTION_CONFIG } from './suggestion-config.js';
5
+ export { levenshteinDistance, computeSimilarity, computeSimilarityIgnoreCase, findClosestMatch, findAllMatches, type ClosestMatch, } from './string-similarity.js';
6
+ export { extractExpectedKeys, extractRequiredKeys, findUnknownKeys, findMissingRequiredKeys, generateExampleValue, generateTemplate, extractEnumValues, } from './schema-introspection.js';
7
+ export { generateSuggestions, formatSuggestionDetails, hasSuggestions, } from './suggestion-generator.js';
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasSuggestions = exports.formatSuggestionDetails = exports.generateSuggestions = exports.extractEnumValues = exports.generateTemplate = exports.generateExampleValue = exports.findMissingRequiredKeys = exports.findUnknownKeys = exports.extractRequiredKeys = exports.extractExpectedKeys = exports.findAllMatches = exports.findClosestMatch = exports.computeSimilarityIgnoreCase = exports.computeSimilarity = exports.levenshteinDistance = exports.MINIMAL_SUGGESTION_CONFIG = exports.DEFAULT_SUGGESTION_CONFIG = exports.isInvalidEnumSuggestion = exports.isMissingRequiredSuggestion = exports.isUnknownKeySuggestion = exports.EMPTY_SUGGESTION_RESULT = exports.similarity = void 0;
4
+ var suggestion_types_js_1 = require("./suggestion-types.js");
5
+ Object.defineProperty(exports, "similarity", { enumerable: true, get: function () { return suggestion_types_js_1.similarity; } });
6
+ Object.defineProperty(exports, "EMPTY_SUGGESTION_RESULT", { enumerable: true, get: function () { return suggestion_types_js_1.EMPTY_SUGGESTION_RESULT; } });
7
+ Object.defineProperty(exports, "isUnknownKeySuggestion", { enumerable: true, get: function () { return suggestion_types_js_1.isUnknownKeySuggestion; } });
8
+ Object.defineProperty(exports, "isMissingRequiredSuggestion", { enumerable: true, get: function () { return suggestion_types_js_1.isMissingRequiredSuggestion; } });
9
+ Object.defineProperty(exports, "isInvalidEnumSuggestion", { enumerable: true, get: function () { return suggestion_types_js_1.isInvalidEnumSuggestion; } });
10
+ var suggestion_config_js_1 = require("./suggestion-config.js");
11
+ Object.defineProperty(exports, "DEFAULT_SUGGESTION_CONFIG", { enumerable: true, get: function () { return suggestion_config_js_1.DEFAULT_SUGGESTION_CONFIG; } });
12
+ Object.defineProperty(exports, "MINIMAL_SUGGESTION_CONFIG", { enumerable: true, get: function () { return suggestion_config_js_1.MINIMAL_SUGGESTION_CONFIG; } });
13
+ var string_similarity_js_1 = require("./string-similarity.js");
14
+ Object.defineProperty(exports, "levenshteinDistance", { enumerable: true, get: function () { return string_similarity_js_1.levenshteinDistance; } });
15
+ Object.defineProperty(exports, "computeSimilarity", { enumerable: true, get: function () { return string_similarity_js_1.computeSimilarity; } });
16
+ Object.defineProperty(exports, "computeSimilarityIgnoreCase", { enumerable: true, get: function () { return string_similarity_js_1.computeSimilarityIgnoreCase; } });
17
+ Object.defineProperty(exports, "findClosestMatch", { enumerable: true, get: function () { return string_similarity_js_1.findClosestMatch; } });
18
+ Object.defineProperty(exports, "findAllMatches", { enumerable: true, get: function () { return string_similarity_js_1.findAllMatches; } });
19
+ var schema_introspection_js_1 = require("./schema-introspection.js");
20
+ Object.defineProperty(exports, "extractExpectedKeys", { enumerable: true, get: function () { return schema_introspection_js_1.extractExpectedKeys; } });
21
+ Object.defineProperty(exports, "extractRequiredKeys", { enumerable: true, get: function () { return schema_introspection_js_1.extractRequiredKeys; } });
22
+ Object.defineProperty(exports, "findUnknownKeys", { enumerable: true, get: function () { return schema_introspection_js_1.findUnknownKeys; } });
23
+ Object.defineProperty(exports, "findMissingRequiredKeys", { enumerable: true, get: function () { return schema_introspection_js_1.findMissingRequiredKeys; } });
24
+ Object.defineProperty(exports, "generateExampleValue", { enumerable: true, get: function () { return schema_introspection_js_1.generateExampleValue; } });
25
+ Object.defineProperty(exports, "generateTemplate", { enumerable: true, get: function () { return schema_introspection_js_1.generateTemplate; } });
26
+ Object.defineProperty(exports, "extractEnumValues", { enumerable: true, get: function () { return schema_introspection_js_1.extractEnumValues; } });
27
+ var suggestion_generator_js_1 = require("./suggestion-generator.js");
28
+ Object.defineProperty(exports, "generateSuggestions", { enumerable: true, get: function () { return suggestion_generator_js_1.generateSuggestions; } });
29
+ Object.defineProperty(exports, "formatSuggestionDetails", { enumerable: true, get: function () { return suggestion_generator_js_1.formatSuggestionDetails; } });
30
+ Object.defineProperty(exports, "hasSuggestions", { enumerable: true, get: function () { return suggestion_generator_js_1.hasSuggestions; } });
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ export declare function extractExpectedKeys(schema: z.ZodType): readonly string[];
3
+ export declare function extractRequiredKeys(schema: z.ZodType): readonly string[];
4
+ export declare function findUnknownKeys(args: unknown, schema: z.ZodType): readonly string[];
5
+ export declare function findMissingRequiredKeys(args: unknown, schema: z.ZodType): readonly string[];
6
+ export declare function generateExampleValue(schema: z.ZodType, depth?: number, maxDepth?: number): unknown;
7
+ export declare function generateTemplate(schema: z.ZodType, maxDepth?: number): Readonly<Record<string, unknown>> | null;
8
+ export declare function extractEnumValues(schema: z.ZodType, path: string): readonly string[];
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractExpectedKeys = extractExpectedKeys;
4
+ exports.extractRequiredKeys = extractRequiredKeys;
5
+ exports.findUnknownKeys = findUnknownKeys;
6
+ exports.findMissingRequiredKeys = findMissingRequiredKeys;
7
+ exports.generateExampleValue = generateExampleValue;
8
+ exports.generateTemplate = generateTemplate;
9
+ exports.extractEnumValues = extractEnumValues;
10
+ const zod_1 = require("zod");
11
+ function extractExpectedKeys(schema) {
12
+ if (schema instanceof zod_1.z.ZodObject) {
13
+ return Object.keys(schema._def.shape());
14
+ }
15
+ return [];
16
+ }
17
+ function extractRequiredKeys(schema) {
18
+ if (!(schema instanceof zod_1.z.ZodObject)) {
19
+ return [];
20
+ }
21
+ const shape = schema._def.shape();
22
+ const required = [];
23
+ for (const [key, value] of Object.entries(shape)) {
24
+ const field = value;
25
+ if (!(field instanceof zod_1.z.ZodOptional) && !(field instanceof zod_1.z.ZodDefault)) {
26
+ required.push(key);
27
+ }
28
+ }
29
+ return required;
30
+ }
31
+ function findUnknownKeys(args, schema) {
32
+ if (typeof args !== 'object' || args === null) {
33
+ return [];
34
+ }
35
+ const expectedKeys = new Set(extractExpectedKeys(schema));
36
+ const providedKeys = Object.keys(args);
37
+ return providedKeys.filter(key => !expectedKeys.has(key));
38
+ }
39
+ function findMissingRequiredKeys(args, schema) {
40
+ if (typeof args !== 'object' || args === null) {
41
+ return extractRequiredKeys(schema);
42
+ }
43
+ const providedKeys = new Set(Object.keys(args));
44
+ const requiredKeys = extractRequiredKeys(schema);
45
+ return requiredKeys.filter(key => !providedKeys.has(key));
46
+ }
47
+ function generateExampleValue(schema, depth = 0, maxDepth = 3) {
48
+ if (depth > maxDepth) {
49
+ return '...';
50
+ }
51
+ if (schema instanceof zod_1.z.ZodDefault) {
52
+ return schema._def.defaultValue();
53
+ }
54
+ if (schema instanceof zod_1.z.ZodOptional) {
55
+ return generateExampleValue(schema._def.innerType, depth, maxDepth);
56
+ }
57
+ if (schema instanceof zod_1.z.ZodObject) {
58
+ const shape = schema._def.shape();
59
+ const result = {};
60
+ for (const [key, value] of Object.entries(shape)) {
61
+ const field = value;
62
+ if (field instanceof zod_1.z.ZodOptional)
63
+ continue;
64
+ result[key] = generateExampleValue(field, depth + 1, maxDepth);
65
+ }
66
+ return result;
67
+ }
68
+ if (schema instanceof zod_1.z.ZodDiscriminatedUnion) {
69
+ const options = schema._def.options;
70
+ if (options.length > 0) {
71
+ return generateExampleValue(options[0], depth + 1, maxDepth);
72
+ }
73
+ return {};
74
+ }
75
+ if (schema instanceof zod_1.z.ZodString) {
76
+ const description = schema._def.description;
77
+ if (description) {
78
+ return `<${description}>`;
79
+ }
80
+ return '<string>';
81
+ }
82
+ if (schema instanceof zod_1.z.ZodNumber) {
83
+ return '<number>';
84
+ }
85
+ if (schema instanceof zod_1.z.ZodBoolean) {
86
+ return '<boolean>';
87
+ }
88
+ if (schema instanceof zod_1.z.ZodArray) {
89
+ return [];
90
+ }
91
+ if (schema instanceof zod_1.z.ZodEnum) {
92
+ const values = schema._def.values;
93
+ if (values.length > 0) {
94
+ return values[0];
95
+ }
96
+ return '<enum>';
97
+ }
98
+ if (schema instanceof zod_1.z.ZodLiteral) {
99
+ return schema._def.value;
100
+ }
101
+ if (schema instanceof zod_1.z.ZodRecord) {
102
+ return {};
103
+ }
104
+ if (schema instanceof zod_1.z.ZodUnknown || schema instanceof zod_1.z.ZodAny) {
105
+ return '<any>';
106
+ }
107
+ if (schema instanceof zod_1.z.ZodEffects) {
108
+ return generateExampleValue(schema._def.schema, depth, maxDepth);
109
+ }
110
+ return '<unknown>';
111
+ }
112
+ function generateTemplate(schema, maxDepth = 3) {
113
+ if (!(schema instanceof zod_1.z.ZodObject)) {
114
+ return null;
115
+ }
116
+ const example = generateExampleValue(schema, 0, maxDepth);
117
+ if (typeof example === 'object' && example !== null) {
118
+ return example;
119
+ }
120
+ return null;
121
+ }
122
+ function extractEnumValues(schema, path) {
123
+ const parts = path.split('.');
124
+ let current = schema;
125
+ for (const part of parts) {
126
+ if (current instanceof zod_1.z.ZodObject) {
127
+ const shape = current._def.shape();
128
+ const field = shape[part];
129
+ if (!field)
130
+ return [];
131
+ current = field;
132
+ }
133
+ else if (current instanceof zod_1.z.ZodOptional) {
134
+ current = current._def.innerType;
135
+ if (current instanceof zod_1.z.ZodObject) {
136
+ const shape = current._def.shape();
137
+ const field = shape[part];
138
+ if (!field)
139
+ return [];
140
+ current = field;
141
+ }
142
+ else {
143
+ return [];
144
+ }
145
+ }
146
+ else {
147
+ return [];
148
+ }
149
+ }
150
+ if (current instanceof zod_1.z.ZodOptional || current instanceof zod_1.z.ZodDefault) {
151
+ current = current._def.innerType;
152
+ }
153
+ if (current instanceof zod_1.z.ZodEnum) {
154
+ return current._def.values;
155
+ }
156
+ if (current instanceof zod_1.z.ZodLiteral) {
157
+ const value = current._def.value;
158
+ if (typeof value === 'string') {
159
+ return [value];
160
+ }
161
+ }
162
+ return [];
163
+ }
@@ -0,0 +1,10 @@
1
+ import { type Similarity } from './suggestion-types.js';
2
+ export declare function levenshteinDistance(a: string, b: string): number;
3
+ export declare function computeSimilarity(a: string, b: string): Similarity;
4
+ export declare function computeSimilarityIgnoreCase(a: string, b: string): Similarity;
5
+ export interface ClosestMatch {
6
+ readonly match: string;
7
+ readonly score: Similarity;
8
+ }
9
+ export declare function findClosestMatch(input: string, candidates: readonly string[], threshold: Similarity): ClosestMatch | null;
10
+ export declare function findAllMatches(input: string, candidates: readonly string[], threshold: Similarity, limit: number): readonly ClosestMatch[];
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.levenshteinDistance = levenshteinDistance;
4
+ exports.computeSimilarity = computeSimilarity;
5
+ exports.computeSimilarityIgnoreCase = computeSimilarityIgnoreCase;
6
+ exports.findClosestMatch = findClosestMatch;
7
+ exports.findAllMatches = findAllMatches;
8
+ const suggestion_types_js_1 = require("./suggestion-types.js");
9
+ function levenshteinDistance(a, b) {
10
+ if (a.length > b.length) {
11
+ [a, b] = [b, a];
12
+ }
13
+ const m = a.length;
14
+ const n = b.length;
15
+ if (m === 0)
16
+ return n;
17
+ if (n === 0)
18
+ return m;
19
+ let prevRow = new Array(m + 1);
20
+ let currRow = new Array(m + 1);
21
+ for (let i = 0; i <= m; i++) {
22
+ prevRow[i] = i;
23
+ }
24
+ for (let j = 1; j <= n; j++) {
25
+ currRow[0] = j;
26
+ for (let i = 1; i <= m; i++) {
27
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
28
+ currRow[i] = Math.min(prevRow[i] + 1, currRow[i - 1] + 1, prevRow[i - 1] + cost);
29
+ }
30
+ [prevRow, currRow] = [currRow, prevRow];
31
+ }
32
+ return prevRow[m];
33
+ }
34
+ function computeSimilarity(a, b) {
35
+ if (a === b)
36
+ return (0, suggestion_types_js_1.similarity)(1);
37
+ if (a.length === 0 || b.length === 0)
38
+ return (0, suggestion_types_js_1.similarity)(0);
39
+ const distance = levenshteinDistance(a, b);
40
+ const maxLength = Math.max(a.length, b.length);
41
+ return (0, suggestion_types_js_1.similarity)(1 - distance / maxLength);
42
+ }
43
+ function computeSimilarityIgnoreCase(a, b) {
44
+ return computeSimilarity(a.toLowerCase(), b.toLowerCase());
45
+ }
46
+ function findClosestMatch(input, candidates, threshold) {
47
+ if (candidates.length === 0)
48
+ return null;
49
+ let bestMatch = null;
50
+ let bestScore = (0, suggestion_types_js_1.similarity)(0);
51
+ for (const candidate of candidates) {
52
+ const score = computeSimilarityIgnoreCase(input, candidate);
53
+ if (score > bestScore && score >= threshold) {
54
+ bestScore = score;
55
+ bestMatch = candidate;
56
+ }
57
+ }
58
+ if (bestMatch === null)
59
+ return null;
60
+ return { match: bestMatch, score: bestScore };
61
+ }
62
+ function findAllMatches(input, candidates, threshold, limit) {
63
+ const matches = [];
64
+ for (const candidate of candidates) {
65
+ const score = computeSimilarityIgnoreCase(input, candidate);
66
+ if (score >= threshold) {
67
+ matches.push({ match: candidate, score });
68
+ }
69
+ }
70
+ matches.sort((a, b) => {
71
+ if (b.score !== a.score)
72
+ return b.score - a.score;
73
+ return a.match.localeCompare(b.match);
74
+ });
75
+ return matches.slice(0, limit);
76
+ }
@@ -0,0 +1,9 @@
1
+ import { type Similarity } from './suggestion-types.js';
2
+ export interface SuggestionConfig {
3
+ readonly similarityThreshold: Similarity;
4
+ readonly maxSuggestions: number;
5
+ readonly includeTemplate: boolean;
6
+ readonly maxTemplateDepth: number;
7
+ }
8
+ export declare const DEFAULT_SUGGESTION_CONFIG: SuggestionConfig;
9
+ export declare const MINIMAL_SUGGESTION_CONFIG: SuggestionConfig;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MINIMAL_SUGGESTION_CONFIG = exports.DEFAULT_SUGGESTION_CONFIG = void 0;
4
+ const suggestion_types_js_1 = require("./suggestion-types.js");
5
+ exports.DEFAULT_SUGGESTION_CONFIG = {
6
+ similarityThreshold: (0, suggestion_types_js_1.similarity)(0.6),
7
+ maxSuggestions: 3,
8
+ includeTemplate: true,
9
+ maxTemplateDepth: 3,
10
+ };
11
+ exports.MINIMAL_SUGGESTION_CONFIG = {
12
+ similarityThreshold: (0, suggestion_types_js_1.similarity)(0.7),
13
+ maxSuggestions: 1,
14
+ includeTemplate: false,
15
+ maxTemplateDepth: 1,
16
+ };
@@ -0,0 +1,6 @@
1
+ import { z } from 'zod';
2
+ import type { SuggestionConfig } from './suggestion-config.js';
3
+ import type { SuggestionResult } from './suggestion-types.js';
4
+ export declare function generateSuggestions(args: unknown, schema: z.ZodType, config: SuggestionConfig): SuggestionResult;
5
+ export declare function formatSuggestionDetails(result: SuggestionResult): Record<string, unknown>;
6
+ export declare function hasSuggestions(result: SuggestionResult): boolean;
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.generateSuggestions = generateSuggestions;
4
+ exports.formatSuggestionDetails = formatSuggestionDetails;
5
+ exports.hasSuggestions = hasSuggestions;
6
+ const zod_1 = require("zod");
7
+ const suggestion_types_js_1 = require("./suggestion-types.js");
8
+ const string_similarity_js_1 = require("./string-similarity.js");
9
+ const schema_introspection_js_1 = require("./schema-introspection.js");
10
+ function generateUnknownKeySuggestions(unknownKeys, expectedKeys, config) {
11
+ const suggestions = [];
12
+ for (const unknownKey of unknownKeys) {
13
+ const match = (0, string_similarity_js_1.findClosestMatch)(unknownKey, expectedKeys, config.similarityThreshold);
14
+ if (match) {
15
+ suggestions.push({
16
+ kind: 'unknown_key',
17
+ provided: unknownKey,
18
+ didYouMean: match.match,
19
+ similarity: match.score,
20
+ });
21
+ }
22
+ }
23
+ suggestions.sort((a, b) => b.similarity - a.similarity);
24
+ return suggestions.slice(0, config.maxSuggestions);
25
+ }
26
+ function generateMissingRequiredSuggestions(missingKeys, schema, config) {
27
+ if (!(schema instanceof zod_1.z.ZodObject)) {
28
+ return [];
29
+ }
30
+ const shape = schema._def.shape();
31
+ const suggestions = [];
32
+ for (const key of missingKeys) {
33
+ const field = shape[key];
34
+ if (field) {
35
+ suggestions.push({
36
+ kind: 'missing_required',
37
+ param: key,
38
+ example: (0, schema_introspection_js_1.generateExampleValue)(field, 0, config.maxTemplateDepth),
39
+ });
40
+ }
41
+ }
42
+ suggestions.sort((a, b) => a.param.localeCompare(b.param));
43
+ return suggestions.slice(0, config.maxSuggestions);
44
+ }
45
+ function generateSuggestions(args, schema, config) {
46
+ const suggestions = [];
47
+ const expectedKeys = (0, schema_introspection_js_1.extractExpectedKeys)(schema);
48
+ const unknownKeys = (0, schema_introspection_js_1.findUnknownKeys)(args, schema);
49
+ const unknownKeySuggestions = generateUnknownKeySuggestions(unknownKeys, expectedKeys, config);
50
+ suggestions.push(...unknownKeySuggestions);
51
+ const missingKeys = (0, schema_introspection_js_1.findMissingRequiredKeys)(args, schema);
52
+ const missingRequiredSuggestions = generateMissingRequiredSuggestions(missingKeys, schema, config);
53
+ suggestions.push(...missingRequiredSuggestions);
54
+ if (suggestions.length === 0 && !config.includeTemplate) {
55
+ return suggestion_types_js_1.EMPTY_SUGGESTION_RESULT;
56
+ }
57
+ const correctTemplate = config.includeTemplate
58
+ ? (0, schema_introspection_js_1.generateTemplate)(schema, config.maxTemplateDepth)
59
+ : null;
60
+ return {
61
+ suggestions,
62
+ correctTemplate,
63
+ };
64
+ }
65
+ function formatSuggestionDetails(result) {
66
+ const details = {};
67
+ if (result.suggestions.length > 0) {
68
+ details.suggestions = result.suggestions.map(s => {
69
+ switch (s.kind) {
70
+ case 'unknown_key':
71
+ return {
72
+ kind: s.kind,
73
+ provided: s.provided,
74
+ didYouMean: s.didYouMean,
75
+ similarity: Math.round(s.similarity * 100) / 100,
76
+ };
77
+ case 'missing_required':
78
+ return {
79
+ kind: s.kind,
80
+ param: s.param,
81
+ example: s.example,
82
+ };
83
+ case 'invalid_enum':
84
+ return {
85
+ kind: s.kind,
86
+ path: s.path,
87
+ provided: s.provided,
88
+ didYouMean: s.didYouMean,
89
+ allowedValues: s.allowedValues,
90
+ };
91
+ }
92
+ });
93
+ }
94
+ if (result.correctTemplate !== null) {
95
+ details.correctTemplate = result.correctTemplate;
96
+ }
97
+ return details;
98
+ }
99
+ function hasSuggestions(result) {
100
+ return result.suggestions.length > 0 || result.correctTemplate !== null;
101
+ }
@@ -0,0 +1,31 @@
1
+ export type Similarity = number & {
2
+ readonly __brand: 'Similarity';
3
+ };
4
+ export declare function similarity(n: number): Similarity;
5
+ export interface UnknownKeySuggestion {
6
+ readonly kind: 'unknown_key';
7
+ readonly provided: string;
8
+ readonly didYouMean: string;
9
+ readonly similarity: Similarity;
10
+ }
11
+ export interface MissingRequiredSuggestion {
12
+ readonly kind: 'missing_required';
13
+ readonly param: string;
14
+ readonly example: unknown;
15
+ }
16
+ export interface InvalidEnumSuggestion {
17
+ readonly kind: 'invalid_enum';
18
+ readonly path: string;
19
+ readonly provided: string;
20
+ readonly didYouMean: string | null;
21
+ readonly allowedValues: readonly string[];
22
+ }
23
+ export type ValidationSuggestion = UnknownKeySuggestion | MissingRequiredSuggestion | InvalidEnumSuggestion;
24
+ export interface SuggestionResult {
25
+ readonly suggestions: readonly ValidationSuggestion[];
26
+ readonly correctTemplate: Readonly<Record<string, unknown>> | null;
27
+ }
28
+ export declare const EMPTY_SUGGESTION_RESULT: SuggestionResult;
29
+ export declare function isUnknownKeySuggestion(s: ValidationSuggestion): s is UnknownKeySuggestion;
30
+ export declare function isMissingRequiredSuggestion(s: ValidationSuggestion): s is MissingRequiredSuggestion;
31
+ export declare function isInvalidEnumSuggestion(s: ValidationSuggestion): s is InvalidEnumSuggestion;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EMPTY_SUGGESTION_RESULT = void 0;
4
+ exports.similarity = similarity;
5
+ exports.isUnknownKeySuggestion = isUnknownKeySuggestion;
6
+ exports.isMissingRequiredSuggestion = isMissingRequiredSuggestion;
7
+ exports.isInvalidEnumSuggestion = isInvalidEnumSuggestion;
8
+ function similarity(n) {
9
+ return Math.max(0, Math.min(1, n));
10
+ }
11
+ exports.EMPTY_SUGGESTION_RESULT = {
12
+ suggestions: [],
13
+ correctTemplate: null,
14
+ };
15
+ function isUnknownKeySuggestion(s) {
16
+ return s.kind === 'unknown_key';
17
+ }
18
+ function isMissingRequiredSuggestion(s) {
19
+ return s.kind === 'missing_required';
20
+ }
21
+ function isInvalidEnumSuggestion(s) {
22
+ return s.kind === 'invalid_enum';
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exaudeus/workrail",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Step-by-step workflow enforcement for AI agents via MCP",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -50,7 +50,7 @@
50
50
  },
51
51
  {
52
52
  "name": "defineNonObvious",
53
- "definition": "A candidate is 'non-obvious' if:\n- It is NOT in `userTopOfMind`, AND\n- It is NOT in the top-N most populous US metros list used for this run (N default 20).\n\nCustom/city/county candidates: if the candidate's displayName contains or is anchored to a top-N metro/city name, treat it as obvious.\n\nRecord the top-N list source and N in the dossier."
53
+ "definition": "A candidate is 'non-obvious' if:\n- It is NOT in `userTopOfMind`, AND\n- It is NOT in the top-N most populous US metros list used for this run (N default 100).\n\nDo NOT use substring matching (\"contains\"/\"anchored to\") to infer obviousness for city/county/custom candidates. If you cannot deterministically map a city/county/custom boundary to an MSA in the top-N list, mark obviousness as Unknown and do not count it toward non-obvious requirements.\n\nAdditionally track `qualifyingNonObviousCandidateCount`: candidates that are non-obvious AND plausibly pass dealbreakers (based on first-pass screening signal).\n\nRecord the top-N list source and N in the dossier."
54
54
  },
55
55
  {
56
56
  "name": "missingDataPolicy",
@@ -65,7 +65,7 @@
65
65
  {
66
66
  "id": "phase-0-scope-and-artifacts",
67
67
  "title": "Phase 0: Scope, Modules, and Paper-Trail Artifacts",
68
- "prompt": "Establish scope, modules, and artifact structure.\n\n1) Confirm v1 scope: US-only relocation.\n2) Capture top-of-mind list (optional): ask the user for `userTopOfMind` (0–10 areas).\n3) Determine user context and activate lightweight modules (select all that apply):\n - kids/schools\n - commute\n - transit\n - climate risk\n - healthcare access\n - career/job market\n - outdoors\n - nightlife/arts\n - safety\n - taxes\n - diversity/community\n - disability accessibility\n\n4) Define primary search granularity for this run (set `candidateType`):\n - Default: metro\n - Optional: city, county, custom\n\n5) Custom areas (v1):\n - Set `customAreaMode = radius`\n - Custom AreaSpec format: center (place+stateCode) + radiusMiles\n\n6) Initialize artifacts (write-or-paste):\n - Master dossier: `RELOCATION_DOSSIER.md`\n - Profiles directory: `relocation-profiles/`\n - Profile naming: `relocation-profiles/<candidate-slug>.md`\n\nIn the dossier, create these sections:\n- User Context & Modules\n- Boundary & Definitions\n- Aggregation & Comparability Policy\n- Preferences (Draft)\n- Constraints & Dealbreakers\n- Missing Data Policy\n- Sources Strategy\n- Candidate Pool (Breadth)\n- Screened Candidates\n- Screening Claims Ledger\n- Shortlist\n- Profiles Index\n- Comparison & Ranking\n- Machine State Checkpoints\n- Decision Log (append-only)\n\n**Set context variables (required):**\n- activeModules: string[]\n- candidateType: metro|city|county|custom\n- customAreaMode: radius\n- userTopOfMind: string[] (empty array allowed)\n\nOutput (in chat):\n- activeModules\n- candidateType\n- customAreaMode\n- userTopOfMind\n- Artifact paths created\n\nThen ask user to confirm modules + candidateType before proceeding.",
68
+ "prompt": "Establish scope, modules, and artifact structure.\n\n1) Confirm v1 scope: US-only relocation.\n2) Capture top-of-mind list (optional): ask the user for `userTopOfMind` (0–10 areas).\n3) Determine user context and activate lightweight modules (select all that apply):\n - kids/schools\n - commute\n - transit\n - climate risk\n - healthcare access\n - career/job market\n - outdoors\n - nightlife/arts\n - safety\n - taxes\n - diversity/community\n - disability accessibility\n - amenities/errands\n - air quality\n - noise\n - internet/infra\n\n4) Define primary search granularity for this run (set `candidateType`):\n - Default: metro\n - Optional: city, county, custom\n\n5) Custom areas (v1):\n - Set `customAreaMode = radius`\n - Custom AreaSpec format: center (place+stateCode) + radiusMiles\n\n6) Initialize artifacts (write-or-paste):\n - Master dossier: `RELOCATION_DOSSIER.md`\n - Profiles directory: `relocation-profiles/`\n - Profile naming: `relocation-profiles/<candidate-slug>.md`\n\nIn the dossier, create these sections:\n- User Context & Modules\n- Boundary & Definitions\n- Aggregation & Comparability Policy\n- Preferences (Draft)\n- Constraints & Dealbreakers\n- Missing Data Policy\n- Sources Strategy\n- Candidate Pool (Breadth)\n- Screened Candidates\n- Screening Claims Ledger\n- Baseline Flags (Not Scored)\n- Red Flag Gate Decisions (append-only)\n- Shortlist\n- Profiles Index\n- Comparison & Ranking\n- Machine State Checkpoints\n- Decision Log (append-only)\n\n**Set context variables (required):**\n- activeModules: string[]\n- candidateType: metro|city|county|custom\n- customAreaMode: radius\n- userTopOfMind: string[] (empty array allowed)\n- timelineToMove: 0-3 months|3-12 months|12+ months\n- householdProfile: string\n- housingPlan: { mode: rent|buy|either, budgetRange?: string }\n- workConstraints: { mode: remote|hybrid|onsite, timeZonesAllowed?: string[] }\n- geoExclusions: { excludeStates?: string[], excludeRegions?: string[] }\n- diversityDimensions: string[]\n\nOutput (in chat):\n- activeModules\n- candidateType\n- customAreaMode\n- userTopOfMind\n- timelineToMove\n- householdProfile\n- housingPlan\n- workConstraints\n- geoExclusions\n- diversityDimensions\n- Artifact paths created\n\nThen ask user to confirm modules + candidateType before proceeding.",
69
69
  "agentRole": "You are a relocation workflow coordinator. Create structure first, then proceed systematically.",
70
70
  "validationCriteria": [
71
71
  {
@@ -87,6 +87,36 @@
87
87
  "type": "contains",
88
88
  "value": "userTopOfMind",
89
89
  "message": "Must set userTopOfMind (can be empty)"
90
+ },
91
+ {
92
+ "type": "contains",
93
+ "value": "timelineToMove",
94
+ "message": "Must set timelineToMove"
95
+ },
96
+ {
97
+ "type": "contains",
98
+ "value": "householdProfile",
99
+ "message": "Must set householdProfile"
100
+ },
101
+ {
102
+ "type": "contains",
103
+ "value": "housingPlan",
104
+ "message": "Must set housingPlan"
105
+ },
106
+ {
107
+ "type": "contains",
108
+ "value": "workConstraints",
109
+ "message": "Must set workConstraints"
110
+ },
111
+ {
112
+ "type": "contains",
113
+ "value": "geoExclusions",
114
+ "message": "Must set geoExclusions (can be empty)"
115
+ },
116
+ {
117
+ "type": "contains",
118
+ "value": "diversityDimensions",
119
+ "message": "Must set diversityDimensions"
90
120
  }
91
121
  ],
92
122
  "requireConfirmation": true
@@ -105,9 +135,29 @@
105
135
  {
106
136
  "id": "phase-1-preference-discovery",
107
137
  "title": "Phase 1: Preference Discovery (Draft) + Calibration Setup",
108
- "prompt": "Discover what the user cares about before searching.\n\n1) Gather constraints:\n- Hard constraints (must-have): geography constraints, climate constraints, max budget, job constraints, family constraints, health constraints.\n- Anti-goals (explicit non-goals).\n- Timeline.\n\n2) Draft preferences as:\n- Dealbreakers\n- Strong preferences\n- Mild preferences\n\n3) Create an initial weight model (draft) across the activated modules:\n- Pick top 6–10 criteria.\n- Assign weights (sum to 100).\n\n**Required output format (exact keys):**\n- weights: [{ criterion: string, weight: number }]\n- weightsCount: <number>\n- weightsSumCheck: 100\n\n4) Update `RELOCATION_DOSSIER.md`:\n- Fill Preferences (Draft)\n- Fill Constraints & Dealbreakers\n- Add initial Weight Model (Draft)\n\nKeep it generic: prefer questions about tradeoffs (e.g., \"Would you trade smaller home for better walkability?\").\n\nOutput: Draft preferences + a short list of open questions (max 5).",
138
+ "prompt": "Discover what the user cares about before searching.\n\n1) Gather constraints:\n- Hard constraints (must-have): geography constraints, climate constraints, max budget, job constraints, family constraints, health constraints.\n- Anti-goals (explicit non-goals).\n- Timeline.\n\n2) Draft preferences as:\n- Dealbreakers\n- Strong preferences\n- Mild preferences\n\n3) Create an initial weight model (draft) across the activated modules:\n- Pick top 6–10 criteria.\n- Assign weights (sum to 100).\n\nIf the user is unsure how to pick numbers, use a temporary equal-weight draft (e.g., 8 criteria → 12,12,12,12,12,12,14,14) and proceed. A later step can help derive better weights via Most/Least comparisons.\n\n**Required output format (exact keys):**\n- dealbreakers: string[]\n- geoConstraints: { includeStates?: string[], excludeStates?: string[], includeRegions?: string[], excludeRegions?: string[], timeZonesAllowed?: string[] }\n- proximityConstraints: { near?: [{ feature: string, maxDriveMinutes?: number, maxMiles?: number }] }\n- climateConstraints: { summerHeat?: low|medium|high, humidityTolerance?: low|medium|high, winterSeverityTolerance?: low|medium|high, sunshineNeed?: low|medium|high, snowIceNoGo?: boolean }\n- urbanFormPreference: { density: dense|mixed|suburban|small-town|rural, walkabilityImportance?: low|medium|high }\n- policyCultureConstraints: { mustHave?: string[], mustAvoid?: string[] }\n- weights: [{ criterion: string, weight: number }]\n- weightsCount: <number>\n- weightsSumCheck: 100\n\n4) Update `RELOCATION_DOSSIER.md`: \n- Fill Preferences (Draft)\n- Fill Constraints & Dealbreakers\n- Add initial Weight Model (Draft)\n\nKeep it generic: prefer questions about tradeoffs (e.g., \"Would you trade smaller home for better walkability?\").\n\nOutput: Draft preferences + a short list of open questions (max 5).",
109
139
  "agentRole": "You are a facilitator eliciting preferences through tradeoffs and constraints.",
110
140
  "validationCriteria": [
141
+ {
142
+ "type": "contains",
143
+ "value": "dealbreakers:",
144
+ "message": "Must output dealbreakers"
145
+ },
146
+ {
147
+ "type": "contains",
148
+ "value": "geoConstraints:",
149
+ "message": "Must output geoConstraints"
150
+ },
151
+ {
152
+ "type": "contains",
153
+ "value": "climateConstraints:",
154
+ "message": "Must output climateConstraints"
155
+ },
156
+ {
157
+ "type": "contains",
158
+ "value": "urbanFormPreference:",
159
+ "message": "Must output urbanFormPreference"
160
+ },
111
161
  {
112
162
  "type": "contains",
113
163
  "value": "weights:",
@@ -126,10 +176,25 @@
126
176
  ],
127
177
  "requireConfirmation": false
128
178
  },
179
+ {
180
+ "id": "phase-1c-weights-maxdiff-optional",
181
+ "title": "Phase 1c: Weight Derivation Helper (MaxDiff, Optional)",
182
+ "prompt": "Optional helper to reduce weight-setting friction.\n\nAsk the user: \"Do you want help deriving weights using Most/Least comparisons? (yes/no)\"\n\nIf NO:\n- Set pairwiseUsed = false\n- Set maxDiffSetsCount = 0\n- Keep the existing weights from Phase 1\n\nIf YES:\n1) Build deterministic MaxDiff sets from the current criteria list (in the order they appear in `weights`).\n - Let N = weightsCount\n - If N <= 7: use 3 sets of 4 criteria\n - If N >= 8: use 4 sets of 5 criteria\n - Sets are rotations of the criteria list (no randomness):\n - set0 = first K\n - set1 = rotate left by 1, take first K\n - set2 = rotate left by 2, take first K\n - set3 = rotate left by 3, take first K (only if 4 sets)\n2) For each set, ask TWO questions:\n - \"Which is MOST important to you?\"\n - \"Which is LEAST important to you?\"\n3) Derive weights deterministically from counts:\n - raw[c] = mostCount[c] - leastCount[c]\n - shifted[c] = raw[c] - min(raw) + 1 (so all >= 1)\n - weight[c] = round(shifted[c] / sum(shifted) * 100)\n - Fix rounding drift by adjusting the largest weight to make the sum exactly 100\n - If all raw values are equal (no signal), keep original weights and note that in weightsDeltaSummary\n4) Show the derived weights and allow ONE small tweak pass:\n - User may adjust up to 2 weights; re-normalize to sum=100\n\nUpdate `RELOCATION_DOSSIER.md` Preferences section:\n- Record whether MaxDiff was used\n- Record the sets and user picks (Most/Least)\n- Record the final weights and 1–5 bullets explaining what changed\n\n**Required output format (exact keys):**\n- pairwiseUsed: true|false\n- maxDiffSetsCount: <number>\n- weights: [{ criterion: string, weight: number }]\n- weightsCount: <number>\n- weightsSumCheck: 100\n- weightsDeltaSummary: [1–5 bullets]",
183
+ "agentRole": "You are helping the user derive stable weights using bounded Most/Least comparisons.",
184
+ "validationCriteria": [
185
+ { "type": "regex", "pattern": "pairwiseUsed:\\s*(true|false)", "message": "Must output pairwiseUsed" },
186
+ { "type": "contains", "value": "maxDiffSetsCount", "message": "Must output maxDiffSetsCount" },
187
+ { "type": "contains", "value": "weights:", "message": "Must output weights array" },
188
+ { "type": "regex", "pattern": "weightsCount:\\s*(6|7|8|9|10)", "message": "weightsCount must be 6–10" },
189
+ { "type": "regex", "pattern": "weightsSumCheck:\\s*100", "message": "weightsSumCheck must be 100" },
190
+ { "type": "contains", "value": "weightsDeltaSummary", "message": "Must output weightsDeltaSummary" }
191
+ ],
192
+ "requireConfirmation": true
193
+ },
129
194
  {
130
195
  "id": "phase-1b-calibration-deck",
131
196
  "title": "Phase 1b: Preference Calibration Deck (Anti-Anchoring)",
132
- "prompt": "Generate a calibration deck of 8–12 diverse US location archetypes (not specific cities yet). Examples: dense transit metro, college town, mountain small city, coastal mid-size, sunbelt suburb, rust-belt revival city, DC-adjacent, etc.\n\nFor each archetype:\n- 2–3 sentences describing lifestyle and typical tradeoffs\n- Who it fits / who it frustrates\n- What it implies about the weight model\n\nAsk user to:\n- Rank top 3 and bottom 3 archetypes\n- Name 1–2 surprises (\"I didn't expect to like...\")\n\nThen update `RELOCATION_DOSSIER.md`:\n- Add Calibration Findings (what changed in preferences)\n- Revise the Weight Model accordingly\n\n**Required output format (exact keys):**\n- calibrationTop3: [string, string, string]\n- calibrationBottom3: [string, string, string]\n- weightsDeltaSummary: [1–5 bullets]\n- weights: [{ criterion: string, weight: number }]\n\nOutput: Updated weight model and what changed because of calibration.",
197
+ "prompt": "Generate a calibration deck of 8–12 diverse US location archetypes (not specific cities yet). Examples: dense transit metro, college town, mountain small city, coastal mid-size, sunbelt suburb, rust-belt revival city, DC-adjacent, etc.\n\nFor each archetype:\n- 2–3 sentences describing lifestyle and typical tradeoffs\n- Who it fits / who it frustrates\n- What it implies about the weight model\n\nAsk user to:\n- Rank top 3 and bottom 3 archetypes\n- Name 1–2 surprises (\"I didn't expect to like...\")\n\nThen update `RELOCATION_DOSSIER.md`:\n- Add Calibration Findings (what changed in preferences)\n- Revise the Weight Model accordingly (weights may have been derived via MaxDiff in Phase 1c)\n\nThen revise (explicitly) any of these if calibration implies changes:\n- geoConstraints\n- climateConstraints\n- urbanFormPreference\n- proximityConstraints\n\n**Required output format (exact keys):**\n- calibrationTop3: [string, string, string]\n- calibrationBottom3: [string, string, string]\n- weightsDeltaSummary: [1–5 bullets]\n- derivedSignals: { densityLeaning: string, climateLeaning: string, regionLeaning: string, travelLeaning: string }\n- weights: [{ criterion: string, weight: number }]\n\nOutput: Updated constraints (if changed), updated weight model, and what changed because of calibration.",
133
198
  "agentRole": "You are an anti-anchoring specialist. Use diversity to reveal latent preferences.",
134
199
  "validationCriteria": [
135
200
  {
@@ -147,6 +212,11 @@
147
212
  "value": "weightsDeltaSummary",
148
213
  "message": "Must output weightsDeltaSummary"
149
214
  },
215
+ {
216
+ "type": "contains",
217
+ "value": "derivedSignals",
218
+ "message": "Must output derivedSignals"
219
+ },
150
220
  {
151
221
  "type": "contains",
152
222
  "value": "weights:",
@@ -158,9 +228,19 @@
158
228
  {
159
229
  "id": "phase-2-policy-and-gates",
160
230
  "title": "Phase 2: Missing-Data Policy + Gates (Lock-In)",
161
- "prompt": "Lock in the decision mechanics before researching candidates.\n\n1) Choose a Missing Data Policy (must be explicit) and record it as `missingDataPolicy`:\n- neutral\n- penalize\n- followup_required\n\n2) Define anti-anchoring gate parameters:\n- minCandidatePool (default 20)\n- minNonObviousCandidates (default 6)\n\n3) Define shortlist range:\n- shortlistMin (default 8)\n- shortlistMax (default 12)\n\n4) Define screening caps (to keep Phase 4 scalable):\n- screeningTopCriteriaCount (default 3) // screen only dealbreakers + top N weighted criteria\n- screeningMaxClaimsPerCandidate (default 3)\n- screeningMaxSourcesPerClaim (default 1)\n- screeningTimeboxMinutesPerCandidate (default 5)\n\n5) Define screening batching (to avoid huge loop iteration limits):\n- screeningBatchSize (default 10)\n\n6) Update `RELOCATION_DOSSIER.md`:\n- Missing Data Policy\n- Anti-Anchoring Gate\n- Shortlist Size Target\n- Screening Caps\n- Screening Batching\n\n**Required output format (exact keys):**\n- missingDataPolicy: neutral|penalize|followup_required\n- minCandidatePool: <number>\n- minNonObviousCandidates: <number>\n- shortlistMin: <number>\n- shortlistMax: <number>\n- screeningTopCriteriaCount: <number>\n- screeningMaxClaimsPerCandidate: <number>\n- screeningTimeboxMinutesPerCandidate: <number>\n- screeningBatchSize: <number>\n- shortlistRangeCheck: ok\n\nAsk user to confirm these policies before proceeding.",
231
+ "prompt": "Lock in the decision mechanics before researching candidates.\n\n1) Choose a Missing Data Policy (must be explicit) and record it as `missingDataPolicy`:\n- neutral\n- penalize\n- followup_required\n\n2) Intake completeness gate (must be explicit):\n- intakeCompletenessCheck: ok|needs_more_info\n- missingInputs: string[] (empty if ok)\n\n3) Define anti-anchoring + diversity gate parameters:\n- minCandidatePool (default 20)\n- minNonObviousCandidates (default 6)\n- minCoverageRegions (default 3)\n- minCoverageClimateBands (default 2)\n\n4) Define shortlist range:\n- shortlistMin (default 8)\n- shortlistMax (default 12)\n\n5) Define screening caps (to keep Phase 4 scalable):\n- screeningTopCriteriaCount (default 3) // screen only dealbreakers + top N weighted criteria\n- screeningMaxClaimsPerCandidate (default 3)\n- screeningMaxSourcesPerClaim (default 1)\n- screeningTimeboxMinutesPerCandidate (default 5)\n\n6) Define screening batching (to avoid huge loop iteration limits):\n- screeningBatchSize (default 10)\n\n7) Define candidate discovery seeding cap (Phase 3 breadth search):\n- perSourceCandidateCap (default 8)\n\n8) Define baseline flags caps (Phase 4 baseline flags):\n- baselineMaxFlagsPerCandidate (default 2)\n- baselineMaxSourcesPerFlag (default 1)\n- baselineTimeboxMinutesPerCandidate (default 2)\n\n9) Update `RELOCATION_DOSSIER.md`:\n- Missing Data Policy\n- Anti-Anchoring Gate\n- Diversity Coverage Gate\n- Shortlist Size Target\n- Screening Caps\n- Screening Batching\n- Discovery Seeding Cap\n- Baseline Flags Caps\n\n**Required output format (exact keys):**\n- intakeCompletenessCheck: ok|needs_more_info\n- missingInputs: string[]\n- missingDataPolicy: neutral|penalize|followup_required\n- minCandidatePool: <number>\n- minNonObviousCandidates: <number>\n- minCoverageRegions: <number>\n- minCoverageClimateBands: <number>\n- shortlistMin: <number>\n- shortlistMax: <number>\n- screeningTopCriteriaCount: <number>\n- screeningMaxClaimsPerCandidate: <number>\n- screeningTimeboxMinutesPerCandidate: <number>\n- screeningBatchSize: <number>\n- perSourceCandidateCap: <number>\n- baselineMaxFlagsPerCandidate: <number>\n- baselineMaxSourcesPerFlag: <number>\n- baselineTimeboxMinutesPerCandidate: <number>\n- shortlistRangeCheck: ok\n\nAsk user to confirm these policies before proceeding.",
162
232
  "agentRole": "You are a decision systems designer. Make ambiguity explicit and policy-driven.",
163
233
  "validationCriteria": [
234
+ {
235
+ "type": "regex",
236
+ "pattern": "intakeCompletenessCheck:\\s*(ok|needs_more_info)",
237
+ "message": "Must set intakeCompletenessCheck: ok|needs_more_info"
238
+ },
239
+ {
240
+ "type": "contains",
241
+ "value": "missingInputs",
242
+ "message": "Must set missingInputs (can be empty)"
243
+ },
164
244
  {
165
245
  "type": "regex",
166
246
  "pattern": "missingDataPolicy:\\s*(neutral|penalize|followup_required)",
@@ -176,6 +256,16 @@
176
256
  "value": "minNonObviousCandidates",
177
257
  "message": "Must set minNonObviousCandidates"
178
258
  },
259
+ {
260
+ "type": "contains",
261
+ "value": "minCoverageRegions",
262
+ "message": "Must set minCoverageRegions"
263
+ },
264
+ {
265
+ "type": "contains",
266
+ "value": "minCoverageClimateBands",
267
+ "message": "Must set minCoverageClimateBands"
268
+ },
179
269
  {
180
270
  "type": "contains",
181
271
  "value": "shortlistMin",
@@ -206,6 +296,26 @@
206
296
  "value": "screeningBatchSize",
207
297
  "message": "Must set screeningBatchSize"
208
298
  },
299
+ {
300
+ "type": "contains",
301
+ "value": "perSourceCandidateCap",
302
+ "message": "Must set perSourceCandidateCap"
303
+ },
304
+ {
305
+ "type": "contains",
306
+ "value": "baselineMaxFlagsPerCandidate",
307
+ "message": "Must set baselineMaxFlagsPerCandidate"
308
+ },
309
+ {
310
+ "type": "contains",
311
+ "value": "baselineMaxSourcesPerFlag",
312
+ "message": "Must set baselineMaxSourcesPerFlag"
313
+ },
314
+ {
315
+ "type": "contains",
316
+ "value": "baselineTimeboxMinutesPerCandidate",
317
+ "message": "Must set baselineTimeboxMinutesPerCandidate"
318
+ },
209
319
  {
210
320
  "type": "regex",
211
321
  "pattern": "shortlistRangeCheck:\\s*ok",
@@ -224,7 +334,7 @@
224
334
  {
225
335
  "id": "phase-3-breadth-search",
226
336
  "title": "Phase 3: Breadth Search (Generate Candidate Pool)",
227
- "prompt": "Generate a broad candidate pool of US areas that plausibly fit the user's constraints.\n\nBefore generating candidates, update `RELOCATION_DOSSIER.md` with a required section:\n- `## Sources Strategy`\n - Housing: Zillow (if available) + at least one alternative\n - Taxes: state revenue sites / reputable summaries\n - Climate normals: NOAA\n - Climate risk: FEMA flood maps + local/state sources where applicable\n - Employment: BLS / state labor stats (if module active)\n - Transit/commute: local transit agencies / reputable summaries (if module active)\n\nRules:\n- Use the Weight Model + Dealbreakers.\n- Generate at least `minCandidatePool` candidates.\n- Ensure at least `minNonObviousCandidates` candidates qualify per defineNonObvious().\n- Every candidate MUST have an AreaSpec.\n- For each candidate, use normalizeCandidate() and record why included.\n- Record the top-N populous metros list used for defineNonObvious() (N default 20) with a source.\n- Include a mix: some candidates the user likely knows + some non-obvious candidates.\n- Optional: include a small number of custom radius candidates if they are plausible and well-defined.\n\n**Set context variables (required):**\n- candidatePool: normalized candidates array\n- candidatePoolCount: number\n- nonObviousCandidateCount: number\n- nonObviousDefinitionUsed: { topN: number, source: string }\n\nUpdate `RELOCATION_DOSSIER.md`:\n- Candidate Pool (Breadth): table with candidate name, candidateType, region, why included, early risks/unknowns\n- Decision Log entry: how the pool was constructed\n\n**Required output format (exact keys):**\n- candidatePoolCount: <number>\n- nonObviousCandidateCount: <number>\n- nonObviousDefinitionUsed: <summary>",
337
+ "prompt": "Generate a broad candidate pool of US areas that plausibly fit the user's constraints.\n\nBefore generating candidates, update `RELOCATION_DOSSIER.md` with a required section:\n- `## Sources Strategy`\n - Housing: Zillow (if available) + at least one alternative\n - Taxes: state revenue sites / reputable summaries\n - Climate normals: NOAA\n - Climate risk: FEMA flood maps + local/state sources where applicable\n - Employment: BLS / state labor stats (if module active)\n - Transit/commute: local transit agencies / reputable summaries (if module active)\n - Air quality: AirNow/EPA + local air district summaries (if module active)\n - Noise: airport noise contour maps + municipal noise resources (if module active)\n - Internet/infra: FCC broadband map + ISP availability (if module active)\n - Amenities/errands: mapping services + local business directories (qualitative; if module active)\n\nRules:\n- Use the Weight Model + Dealbreakers.\n- Discovery breadth must be systematic: tag each candidate with `candidateFacets` (region, climateBand, sizeTier, taxRegime, airportAccess, outdoorsBiome as applicable) and fill obvious coverage gaps.\n- When using curated list sources for discovery, cap contributions to `perSourceCandidateCap` candidates per source to avoid editorial bias dominating.\n- Generate at least `minCandidatePool` candidates.\n- Ensure at least `minNonObviousCandidates` candidates qualify as *qualifying non-obvious* (non-obvious per defineNonObvious() AND plausibly passes dealbreakers based on first-pass screening signal).\n- Every candidate MUST have an AreaSpec.\n- For each candidate, use normalizeCandidate() and record why included.\n- Record the top-N populous metros list used for defineNonObvious() (N default 100) with a source.\n- Include a mix: some candidates the user likely knows + some non-obvious candidates.\n- Optional: include a small number of custom radius candidates if they are plausible and well-defined.\n\n**Set context variables (required):**\n- candidatePool: normalized candidates array\n- candidatePoolCount: number\n- nonObviousCandidateCount: number\n- qualifyingNonObviousCandidateCount: number\n- coverageRegionsCount: number\n- coverageClimateBandsCount: number\n- candidateFacetsSummary: { regions: string[], climateBands: string[], sizeTiers: string[] }\n- coverageMatrix: <summary>\n- coverageGaps: string[]\n- discoverySourcesUsed: { name: string, countAdded: number }[]\n- perSourceCandidateCap: number\n- nonObviousDefinitionUsed: { topN: number, source: string }\n\nUpdate `RELOCATION_DOSSIER.md`:\n- Candidate Pool (Breadth): table with candidate name, candidateType, region, why included, early risks/unknowns\n- Decision Log entry: how the pool was constructed\n\n**Required output format (exact keys):**\n- candidatePoolCount: <number>\n- nonObviousCandidateCount: <number>\n- qualifyingNonObviousCandidateCount: <number>\n- coverageRegionsCount: <number>\n- coverageClimateBandsCount: <number>\n- coverageGaps: <summary>\n- discoverySourcesUsed: <summary>\n- nonObviousDefinitionUsed: <summary>",
228
338
  "agentRole": "You are a researcher generating a diverse, constraint-respecting candidate pool.",
229
339
  "validationCriteria": [
230
340
  {
@@ -242,6 +352,26 @@
242
352
  "value": "nonObviousCandidateCount",
243
353
  "message": "Must set nonObviousCandidateCount"
244
354
  },
355
+ {
356
+ "type": "contains",
357
+ "value": "qualifyingNonObviousCandidateCount",
358
+ "message": "Must set qualifyingNonObviousCandidateCount"
359
+ },
360
+ {
361
+ "type": "contains",
362
+ "value": "coverageRegionsCount",
363
+ "message": "Must set coverageRegionsCount"
364
+ },
365
+ {
366
+ "type": "contains",
367
+ "value": "coverageClimateBandsCount",
368
+ "message": "Must set coverageClimateBandsCount"
369
+ },
370
+ {
371
+ "type": "contains",
372
+ "value": "discoverySourcesUsed",
373
+ "message": "Must record discoverySourcesUsed"
374
+ },
245
375
  {
246
376
  "type": "contains",
247
377
  "value": "nonObviousDefinitionUsed",
@@ -253,7 +383,7 @@
253
383
  {
254
384
  "id": "phase-3b-anti-anchoring-gate-check",
255
385
  "title": "Phase 3b: Anti-Anchoring Gate Check",
256
- "prompt": "Run antiAnchoringGate() deterministically using these comparisons:\n- candidatePoolCount >= minCandidatePool\n- nonObviousCandidateCount >= minNonObviousCandidates\n\nIf the gate fails:\n- Expand the pool until it passes (diversify; avoid adding only obvious metros).\n- Recompute candidatePoolCount and nonObviousCandidateCount.\n\nIf the gate passes:\n- Proceed.\n\n**Required output format (exact keys):**\n- antiAnchoringGate: pass|fail\n- gateFailureReason: <string or empty>\n- poolExpansionCount: <number>\n\nUpdate `RELOCATION_DOSSIER.md` with gate status and any expansions performed.",
386
+ "prompt": "Run antiAnchoringGate() deterministically using these comparisons:\n- candidatePoolCount >= minCandidatePool\n- qualifyingNonObviousCandidateCount >= minNonObviousCandidates\n- coverageRegionsCount >= minCoverageRegions\n- coverageClimateBandsCount >= minCoverageClimateBands\n\nIf the gate fails:\n- Expand the pool until it passes by filling coverage gaps first (diversify; avoid adding only obvious metros).\n- Recompute candidatePoolCount, qualifyingNonObviousCandidateCount, coverageRegionsCount, and coverageClimateBandsCount.\n\nIf the gate passes:\n- Proceed.\n\n**Required output format (exact keys):**\n- antiAnchoringGate: pass|fail\n- gateFailureReason: <string or empty>\n- poolExpansionCount: <number>\n\nUpdate `RELOCATION_DOSSIER.md` with gate status and any expansions performed.",
257
387
  "agentRole": "You enforce anti-anchoring and minimum diversity requirements.",
258
388
  "validationCriteria": [
259
389
  {
@@ -324,10 +454,41 @@
324
454
  }
325
455
  ]
326
456
  },
457
+ {
458
+ "id": "phase-4aa-baseline-flags",
459
+ "title": "Phase 4aa: Baseline Flags (Not Scored)",
460
+ "prompt": "Perform a lightweight baseline due diligence pass (NOT scored) for candidates that survived screening.\n\nInput: use `screenResults` to identify candidates that are Pass or Maybe.\n\nBaseline scope (keep bounded):\n- Climate risk (high-level)\n- Safety/crime (high-level)\n- If relevant to householdProfile or activeModules: schools and healthcare access (high-level)\n\nCaps (from Phase 2):\n- baselineMaxFlagsPerCandidate\n- baselineMaxSourcesPerFlag\n- baselineTimeboxMinutesPerCandidate\n\nRules:\n- Do not compute or modify ranking scores here.\n- Do not silently turn flags into dealbreakers or weights.\n- If evidence is unclear, record Unknown and add to unknowns.\n\nFor each Pass/Maybe candidate, produce 0..baselineMaxFlagsPerCandidate baseline flags. Each flag must be tagged:\n- category: climate|safety|schools|healthcare|policy|other\n- severity: yellow|orange|red\n- summary: one sentence\n- source (URL/citation)\n- retrievedAt\n- confidenceGrade (High/Medium/Low)\n\nRed flag definition (v1): any flag with severity=red.\n\nUpdate `RELOCATION_DOSSIER.md`:\n- Add/append a Baseline Flags (Not Scored) section with a per-candidate table of flags + unknowns.\n\n**Set context variables (required):**\n- baselineFlags: { [candidateKey: string]: { flags: array, unknowns: string[] } }\n- redFlagCandidates: string[]\n- redFlagCount: number\n\n**Required output format (exact keys):**\n- redFlagCount: <number>\n- redFlagCandidates: <list>\n- baselineFlags: <present>",
461
+ "agentRole": "You are doing bounded baseline due diligence without affecting scoring.",
462
+ "validationCriteria": [
463
+ { "type": "contains", "value": "baselineFlags", "message": "Must set baselineFlags" },
464
+ { "type": "contains", "value": "redFlagCandidates", "message": "Must set redFlagCandidates" },
465
+ { "type": "contains", "value": "redFlagCount", "message": "Must set redFlagCount" }
466
+ ],
467
+ "requireConfirmation": false
468
+ },
469
+ {
470
+ "id": "phase-4ab-red-flag-gate",
471
+ "title": "Phase 4ab: Red Flag Gate (User Decision)",
472
+ "prompt": "Handle baseline red flags explicitly before shortlisting.\n\nIf redFlagCount = 0:\n- Output redFlagDecision = fyi\n- Output redFlagDecisionNotes = \"No red flags detected in baseline due diligence\"\n- Proceed.\n\nIf redFlagCount > 0:\n1) Summarize each red flag (candidate + category + one-line summary + source).\n2) Ask user which one to do (pick exactly one):\n - promote_to_dealbreakers\n - add_weighted_criterion\n - fyi\n\nIf promote_to_dealbreakers:\n- Update Dealbreakers in `RELOCATION_DOSSIER.md` and state the new/updated dealbreakers explicitly.\n\nIf add_weighted_criterion:\n- Ask user how it should be weighted and which existing weight(s) should decrease so weights still sum to 100.\n\nIf fyi:\n- Record decision in `RELOCATION_DOSSIER.md` Red Flag Gate Decisions (append-only).\n\n**Required output format (exact keys):**\n- redFlagDecision: promote_to_dealbreakers|add_weighted_criterion|fyi\n- redFlagDecisionNotes: <string>",
473
+ "agentRole": "You enforce explicit user intent for red flags (no hidden weighting).",
474
+ "validationCriteria": [
475
+ {
476
+ "type": "regex",
477
+ "pattern": "redFlagDecision:\\s*(promote_to_dealbreakers|add_weighted_criterion|fyi)",
478
+ "message": "Must set redFlagDecision"
479
+ },
480
+ {
481
+ "type": "contains",
482
+ "value": "redFlagDecisionNotes",
483
+ "message": "Must set redFlagDecisionNotes"
484
+ }
485
+ ],
486
+ "requireConfirmation": { "var": "redFlagCount", "gt": 0 }
487
+ },
327
488
  {
328
489
  "id": "phase-4b-select-shortlist",
329
490
  "title": "Phase 4b: Select Shortlist for Deep Dives",
330
- "prompt": "Select a shortlist for deep dives.\n\nRules:\n- Target shortlist size: within shortlistMin..shortlistMax.\n- Must include at least 3 candidates outside the user's `userTopOfMind` list (if provided).\n- If too many Pass/Maybe, prefer diversity across archetypes.\n\n**Set context variables (required):**\n- shortlist: normalized candidates array\n- shortlistCount: number\n- shortlistNonTopOfMindCount: number\n\n**Required output format (exact keys):**\n- shortlistCount: <number>\n- shortlistNonTopOfMindCount: <number>\n- shortlistRangeCheck: ok\n\nUpdate `RELOCATION_DOSSIER.md`:\n- Shortlist section with rationale per shortlisted candidate\n- Profiles Index (planned profile files)\n\nAsk user to confirm the shortlist before deep dives.",
491
+ "prompt": "Select a shortlist for deep dives.\n\nRules:\n- Target shortlist size: within shortlistMin..shortlistMax.\n- Must include at least 3 candidates outside the user's `userTopOfMind` list (if provided).\n- If too many Pass/Maybe, prefer diversity across archetypes.\n- Baseline flags are NOT scored, but shortlist rationale must call out any red/orange baseline flags for shortlisted candidates (briefly).\n\n**Set context variables (required):**\n- shortlist: normalized candidates array\n- shortlistCount: number\n- shortlistNonTopOfMindCount: number\n- shortlistNonObviousCount: number\n- shortlistRedFlagCount: number\n\n**Required output format (exact keys):**\n- shortlistCount: <number>\n- shortlistNonTopOfMindCount: <number>\n- shortlistNonObviousCount: <number>\n- shortlistRedFlagCount: <number>\n- shortlistRangeCheck: ok\n\nUpdate `RELOCATION_DOSSIER.md`:\n- Shortlist section with rationale per shortlisted candidate\n- Profiles Index (planned profile files)\n\nAsk user to confirm the shortlist before deep dives.",
331
492
  "agentRole": "You are a curator optimizing for diversity, fit, and decision usefulness.",
332
493
  "validationCriteria": [
333
494
  {
@@ -345,6 +506,16 @@
345
506
  "value": "shortlistNonTopOfMindCount",
346
507
  "message": "Must set shortlistNonTopOfMindCount"
347
508
  },
509
+ {
510
+ "type": "contains",
511
+ "value": "shortlistNonObviousCount",
512
+ "message": "Must set shortlistNonObviousCount"
513
+ },
514
+ {
515
+ "type": "contains",
516
+ "value": "shortlistRedFlagCount",
517
+ "message": "Must set shortlistRedFlagCount"
518
+ },
348
519
  {
349
520
  "type": "regex",
350
521
  "pattern": "shortlistRangeCheck:\\s*ok",
@@ -375,7 +546,7 @@
375
546
  {
376
547
  "id": "phase-5a-write-profile",
377
548
  "title": "Profile Deep Dive: {{shortCandidate.name}}",
378
- "prompt": "Create/update the per-candidate profile doc at `relocation-profiles/<candidate-slug>.md`.\n\nRequired: include boundary explicitly at the top:\n- CandidateType\n- AreaSpec (exact boundary)\n\nModule-driven rule (required):\n- Include a section ONLY if its module is active in `activeModules`.\n- If a module is inactive, omit the section (do not include placeholders).\n\nProfile template (must follow):\n- Summary (who it fits / who it doesn't)\n- Housing (rent/buy ranges, inventory notes, neighborhood variation)\n- Cost of living (beyond housing)\n- Taxes (income/property/sales; major gotchas)\n- Safety (high-level + neighborhood variance; avoid false precision)\n- Schools/childcare (module: kids/schools)\n- Commute/transit (modules: commute, transit)\n- Healthcare access (module: healthcare access)\n- Climate & climate risk (module: climate risk)\n- Job market (module: career/job market)\n- Lifestyle (modules: outdoors, nightlife/arts, diversity/community)\n- Pros / Cons (evidence-backed)\n\n**Required headings (non-optional):**\n- ## Unknowns & follow-ups\n- ## Claims & Sources\n\nClaims & Sources ledger requirements:\n- Every key claim uses trackClaim() fields: claim, source, retrievedAt, confidenceGrade.\n- If a claim is proxy/aggregate (especially for custom areas), label it as such.\n\nAlso update `RELOCATION_DOSSIER.md`:\n- Add a short entry for this candidate (1 paragraph) linking to the profile and summarizing differentiators.\n\nWrite-or-paste applies.",
549
+ "prompt": "Create/update the per-candidate profile doc at `relocation-profiles/<candidate-slug>.md`.\n\nRequired: include boundary explicitly at the top:\n- CandidateType\n- AreaSpec (exact boundary)\n\nModule-driven rule (required):\n- Include a section ONLY if its module is active in `activeModules`.\n- If a module is inactive, omit the section (do not include placeholders).\n\nProfile template (must follow):\n- Summary (who it fits / who it doesn't)\n- Housing (rent/buy ranges, inventory notes, neighborhood variation)\n- Cost of living (beyond housing)\n- Taxes (income/property/sales; major gotchas)\n- Safety (high-level + neighborhood variance; avoid false precision)\n- Schools/childcare (module: kids/schools)\n- Commute/transit (modules: commute, transit)\n- Healthcare access (module: healthcare access)\n- Climate & climate risk (module: climate risk)\n- Job market (module: career/job market)\n- Lifestyle (modules: outdoors, nightlife/arts, diversity/community)\n- Amenities & errands (module: amenities/errands)\n- Air quality (module: air quality)\n- Noise (module: noise)\n- Internet & infrastructure (module: internet/infra)\n- Pros / Cons (evidence-backed)\n\n**Required headings (non-optional):**\n- ## Baseline Flags (Not Scored)\n- ## Unknowns & follow-ups\n- ## Claims & Sources\n\nBaseline Flags (Not Scored) requirements:\n- Copy any baseline flags already discovered in Phase 4aa for this candidate (if present).\n- If absent or stale, do a quick refresh within the active modules (bounded; do not over-research).\n- Explicitly state: \"These baseline flags do not affect scoring unless the user chooses to promote them.\"\n\nClaims & Sources ledger requirements:\n- Every key claim uses trackClaim() fields: claim, source, retrievedAt, confidenceGrade.\n- If a claim is proxy/aggregate (especially for custom areas), label it as such.\n\nAlso update `RELOCATION_DOSSIER.md`: \n- Add a short entry for this candidate (1 paragraph) linking to the profile and summarizing differentiators.\n\nWrite-or-paste applies.",
379
550
  "agentRole": "You are a meticulous researcher producing consistent, evidence-backed location profiles.",
380
551
  "validationCriteria": [
381
552
  {
@@ -387,6 +558,11 @@
387
558
  "type": "contains",
388
559
  "value": "## Unknowns & follow-ups",
389
560
  "message": "Profile must include '## Unknowns & follow-ups'"
561
+ },
562
+ {
563
+ "type": "contains",
564
+ "value": "## Baseline Flags (Not Scored)",
565
+ "message": "Profile must include '## Baseline Flags (Not Scored)'"
390
566
  }
391
567
  ],
392
568
  "requireConfirmation": false
@@ -396,7 +572,7 @@
396
572
  {
397
573
  "id": "phase-6-compare-and-rank",
398
574
  "title": "Phase 6: Comparison & Explainable Ranking",
399
- "prompt": "Produce the final comparison and ranking.\n\n1) Build a comparison matrix in `RELOCATION_DOSSIER.md`:\n- Rows: shortlisted candidates\n- Columns: the weighted criteria\n- Include Unknown markers explicitly\n\n2) Deterministic scoring model (required):\n- For each criterion, assign a normalized subscore:\n - Strong fit = 1.0\n - Mixed/conditional fit = 0.5\n - Weak fit = 0.0\n - Unknown = depends on missingDataPolicy\n- Missing data handling (must be explicit and consistent):\n - missingDataPolicy=neutral → Unknown subscore = 0.5\n - missingDataPolicy=penalize → Unknown subscore = 0.25\n - missingDataPolicy=followup_required → Unknown subscore = 0.5 AND candidate is ineligible for top 3 if it has Unknown on any criterion with weight >= 15\n\nScore formula:\n- totalScore = Σ (weight_i * subscore_i)\n\n3) For each candidate, add an explainable narrative:\n- \"Ranks #k because it wins on X/Y and loses on Z. Biggest tradeoff: ...\"\n\n4) Produce final ranked list (top to bottom) with confidence notes and key caveats.\n\n5) Re-weight gate (bounded):\n- Ask user to confirm if ranking is directionally correct.\n- If not, allow ONE re-weight of `weights` and re-run scoring.\n- Output `reweightUsed: true|false`.\n\n**Required output format (exact keys):**\n- ranking: [{ name: string, totalScore: number, rank: number }]\n- unknownsImpactSummary: <string>\n- reweightUsed: true|false\n\nUpdate Decision Log with any weight changes and rationale.",
575
+ "prompt": "Produce the final comparison and ranking.\n\n1) Build a comparison matrix in `RELOCATION_DOSSIER.md`:\n- Rows: shortlisted candidates\n- Columns: the weighted criteria\n- Include Unknown markers explicitly\n\nAlso include a separate, clearly labeled appendix/table for baseline flags:\n- Baseline Flags (Not Scored): summarize red/orange baseline flags per candidate\n- These baseline flags do NOT change totalScore\n\n2) Deterministic scoring model (required):\n- For each criterion, assign a normalized subscore:\n - Strong fit = 1.0\n - Mixed/conditional fit = 0.5\n - Weak fit = 0.0\n - Unknown = depends on missingDataPolicy\n- Missing data handling (must be explicit and consistent):\n - missingDataPolicy=neutral → Unknown subscore = 0.5\n - missingDataPolicy=penalize → Unknown subscore = 0.25\n - missingDataPolicy=followup_required → Unknown subscore = 0.5 AND candidate is ineligible for top 3 if it has Unknown on any criterion with weight >= 15\n\nScore formula:\n- totalScore = Σ (weight_i * subscore_i)\n\n3) For each candidate, add an explainable narrative:\n- \"Ranks #k because it wins on X/Y and loses on Z. Biggest tradeoff: ...\"\n\n4) Produce final ranked list (top to bottom) with confidence notes and key caveats.\n\n5) Re-weight gate (bounded):\n- Ask user to confirm if ranking is directionally correct.\n- If not, allow ONE re-weight of `weights` and re-run scoring.\n- Output `reweightUsed: true|false`.\n\n**Required output format (exact keys):**\n- ranking: [{ name: string, totalScore: number, rank: number }]\n- unknownsImpactSummary: <string>\n- reweightUsed: true|false\n\nUpdate Decision Log with any weight changes and rationale.",
400
576
  "agentRole": "You are an analyst producing an explainable, evidence-backed ranking with explicit tradeoffs.",
401
577
  "validationCriteria": [
402
578
  {