@sppg2001/atomize 4.0.1 → 4.1.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.
package/dist/cli/index.js CHANGED
@@ -178,6 +178,30 @@ var init_errors = __esm(() => {
178
178
  };
179
179
  });
180
180
 
181
+ // ../atomize-schema/src/estimation-fields.ts
182
+ function matchesFieldPattern(referenceName, patterns = []) {
183
+ return patterns.some((pattern) => pattern.endsWith(".*") ? referenceName.startsWith(pattern.slice(0, -1)) : referenceName === pattern);
184
+ }
185
+ function isPlatformExcluded(field, rules) {
186
+ return field.referenceName.startsWith("System.") || matchesFieldPattern(field.referenceName, rules?.nonEstimateFields);
187
+ }
188
+ function isEstimateSourceField(field, rules) {
189
+ if (field.isReadOnly)
190
+ return false;
191
+ if (rules?.storyEstimateFields?.includes(field.referenceName))
192
+ return true;
193
+ if (isPlatformExcluded(field, rules))
194
+ return false;
195
+ return field.type === "integer" || field.type === "decimal" || field.type === "string" && !field.isMultiline;
196
+ }
197
+ function isEstimateTargetField(field, rules) {
198
+ return !field.isReadOnly && !field.isPicklist && (field.type === "integer" || field.type === "decimal") && !isPlatformExcluded(field, rules);
199
+ }
200
+ function sortEstimateFields(fields) {
201
+ const custom = (field) => Number(field.isCustom ?? field.referenceName.startsWith("Custom."));
202
+ return [...fields].sort((a, b) => custom(b) - custom(a) || a.name.localeCompare(b.name));
203
+ }
204
+
181
205
  // ../atomize-schema/src/estimation-normalizer.ts
182
206
  function normalizeEstimationPercentages(items, options = {}) {
183
207
  const { skipIfAlreadyNormalized = true, tolerance = 0.01, targetTotal = 100 } = options;
@@ -20065,6 +20089,25 @@ var init_zod = __esm(() => {
20065
20089
  });
20066
20090
 
20067
20091
  // ../atomize-schema/src/schema.ts
20092
+ function lookupTableEstimate(value, table, multipliers = false) {
20093
+ const text = String(value).trim();
20094
+ if (Object.hasOwn(table, text))
20095
+ return table[text];
20096
+ if (!multipliers)
20097
+ return;
20098
+ const candidates = Object.keys(table).filter((key) => key !== "" && text.endsWith(key)).sort((a, b) => b.length - a.length);
20099
+ for (const key of candidates) {
20100
+ const prefix = text.slice(0, text.length - key.length).trim();
20101
+ if (NUMBER_PATTERN.test(prefix))
20102
+ return Number(prefix) * table[key];
20103
+ }
20104
+ return;
20105
+ }
20106
+ function isConvertibleStoryEstimate(value, conversion) {
20107
+ if (conversion?.table)
20108
+ return lookupTableEstimate(value, conversion.table, conversion.multipliers) !== undefined;
20109
+ return typeof value === "number" || value.trim() !== "" && Number.isFinite(Number(value));
20110
+ }
20068
20111
  function ownPercentRange(task) {
20069
20112
  const candidates = [task.estimationPercent ?? 0, ...task.estimationPercentCondition?.map((rule) => rule.percent) ?? []];
20070
20113
  return [Math.min(...candidates), Math.max(...candidates)];
@@ -20162,7 +20205,7 @@ function reportCircularDependencies(tasks, taskIndexById, ctx) {
20162
20205
  });
20163
20206
  }
20164
20207
  }
20165
- var DATE_OR_MACRO_RE, DateOrMacroSchema, REFERENCE_NAME_RE, ConditionOperatorSchema, ConditionValueSchema, MULTI_VALUE_STANDARD_FIELDS, MULTI_VALUE_FIELD_OPERATORS, ConditionSchema, SavedQuerySchema, FilterCriteriaSchema, EstimationPercentConditionSchema, TaskDefinitionSchema, EstimationConfigSchema, ValidationModeSchema, ValidationConfigSchema, MetadataSchema, TaskTemplateBaseSchema, ExtendingTaskTemplateSchema, TaskTemplateSchema, MixinTemplateSchema, CURRENT_ITERATION = "@CurrentIteration", TEAM_AREAS = "@TeamAreas";
20208
+ var DATE_OR_MACRO_RE, DateOrMacroSchema, REFERENCE_NAME_RE, ConditionOperatorSchema, ConditionValueSchema, MULTI_VALUE_STANDARD_FIELDS, MULTI_VALUE_FIELD_OPERATORS, ConditionSchema, SavedQuerySchema, FilterCriteriaSchema, EstimationPercentConditionSchema, TaskDefinitionSchema, NUMBER_PATTERN, EstimationConversionSchema, EstimationConfigSchema, ValidationModeSchema, ValidationConfigSchema, MetadataSchema, TaskTemplateBaseSchema, ExtendingTaskTemplateSchema, TaskTemplateSchema, MixinTemplateSchema, CURRENT_ITERATION = "@CurrentIteration", TEAM_AREAS = "@TeamAreas";
20166
20209
  var init_schema = __esm(() => {
20167
20210
  init_zod();
20168
20211
  DATE_OR_MACRO_RE = /^(@Today|@StartOfDay|@StartOfMonth|@StartOfWeek|@StartOfYear)(\s*[+-]\s*\d+)?$|^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/i;
@@ -20219,7 +20262,7 @@ var init_schema = __esm(() => {
20219
20262
  });
20220
20263
  FilterCriteriaSchema = exports_external.object({
20221
20264
  team: exports_external.string().describe("Azure DevOps team name. Limits story selection to stories owned by this team.").optional(),
20222
- workItemTypes: exports_external.array(exports_external.string()).describe("Work item types to include (e.g. ['User Story', 'Bug']).").optional(),
20265
+ workItemTypes: exports_external.array(exports_external.string()).describe("Work item types to include (e.g. ['User Story', 'Bug']). With savedQuery, declares which types the query returns: it doesn't filter, but tells Atomize which fields to look up and validate.").optional(),
20223
20266
  states: exports_external.array(exports_external.string()).describe("Story states to target (e.g. ['Active', 'New']).").optional(),
20224
20267
  statesExclude: exports_external.array(exports_external.string()).describe("Story states to exclude from selection.").optional(),
20225
20268
  statesWereEver: exports_external.array(exports_external.string()).describe("Include stories that were ever in any of these states.").optional(),
@@ -20276,14 +20319,42 @@ var init_schema = __esm(() => {
20276
20319
  return;
20277
20320
  })
20278
20321
  }).strict();
20322
+ NUMBER_PATTERN = /^(?:\d+(?:\.\d+)?|\.\d+)$/;
20323
+ EstimationConversionSchema = exports_external.object({
20324
+ factor: exports_external.number().positive().describe("Multiplier from the Story's unit to the Task's unit, e.g. 4 when 1 point = 4 hours.").optional(),
20325
+ table: exports_external.record(exports_external.string(), exports_external.number().nonnegative()).refine((table) => Object.keys(table).length > 0, "conversion.table must have at least one entry").describe("Story Estimate value to Task-unit amount, matched exactly, e.g. { XS: 1, S: 2, M: 4, L: 5 } or { 1: 2, 2: 4, 3: 8 } for scales that aren't linear.").optional(),
20326
+ multipliers: exports_external.boolean().describe("Also accept a number followed by a table key, e.g. '0.3XL' = 0.3 × XL. Exact keys still win. Requires a table with non-numeric keys.").optional()
20327
+ }).strict().refine((conversion) => conversion.factor === undefined !== (conversion.table === undefined), {
20328
+ message: "conversion must set exactly one of factor or table"
20329
+ }).refine((conversion) => !conversion.multipliers || conversion.table !== undefined, {
20330
+ message: "conversion.multipliers requires conversion.table",
20331
+ path: ["multipliers"]
20332
+ }).refine((conversion) => !conversion.multipliers || !conversion.table || !Object.keys(conversion.table).every((key) => NUMBER_PATTERN.test(key)), {
20333
+ message: "conversion.multipliers cannot be used when every table key is numeric: '25' would be ambiguous between the key 25 and 2 × 5",
20334
+ path: ["multipliers"]
20335
+ }).describe("Translates the Story Estimate into the Task's unit before it is split across tasks. Set exactly one of factor or table; omit for one-to-one.");
20279
20336
  EstimationConfigSchema = exports_external.object({
20280
20337
  strategy: exports_external.enum(["percentage"]).optional().default("percentage").describe("Estimation strategy. 'percentage' distributes the parent story's estimate across tasks proportionally."),
20281
- source: exports_external.string().describe("Field on the parent story to read the estimation value from (e.g. 'story-points').").optional(),
20338
+ source: exports_external.string().min(1).describe("Story field that supplies the Story Estimate, as a platform field reference name (e.g. 'Custom.TShirtSize'). Replaces the platform's default fields, with no fallback. Omit to use the platform default.").optional(),
20339
+ conversion: EstimationConversionSchema.optional(),
20340
+ targetFields: exports_external.array(exports_external.string().min(1)).min(1, "targetFields must name at least one field").describe("Task fields that receive the Task Estimate, as platform field reference names. Replaces the platform's default estimate fields entirely; values are written exactly as calculated.").optional(),
20341
+ normalize: exports_external.enum(["auto", "always", "never"]).describe("What to do when the generated tasks' percentages don't add up to 100%. 'auto' (default) scales totals below 100% up and leaves totals above 100% as written unless you choose otherwise at generate time; 'always' scales to 100% in both directions; 'never' uses the percentages exactly as written, so a skipped task's share stays unallocated.").optional(),
20282
20342
  rounding: exports_external.enum(["nearest", "up", "down", "none"]).optional().default("none").describe("Rounding mode applied to calculated task estimates: 'nearest', 'up', 'down', or 'none'."),
20283
- minimumTaskPoints: exports_external.number().describe("Minimum point value any single task can receive after rounding.").optional(),
20284
- ifParentHasNoEstimation: exports_external.enum(["skip", "warn", "use-default"]).describe("Behaviour when the parent story has no estimate. 'skip' omits tasks, 'warn' logs a warning, 'use-default' falls back to defaultParentEstimation.").optional(),
20285
- defaultParentEstimation: exports_external.number().describe("Fallback estimation value used when the parent story has no estimate and ifParentHasNoEstimation is 'use-default'.").optional()
20286
- }).strict();
20343
+ minimumTaskEstimate: exports_external.number().describe("Minimum Task Estimate any single task can receive after rounding, in the Task's unit.").optional(),
20344
+ minimumTaskPoints: exports_external.number().describe("Deprecated: use minimumTaskEstimate. Still honoured when minimumTaskEstimate is absent.").meta({ deprecated: true }).optional(),
20345
+ ifParentHasNoEstimation: exports_external.enum(["skip", "warn", "use-default"]).describe("Behaviour when the Story Estimate is missing or cannot be converted. 'warn' (default) creates the tasks with blank estimates and reports a warning, 'skip' creates no tasks for the Story, 'use-default' uses defaultParentEstimation instead.").optional(),
20346
+ defaultParentEstimation: exports_external.union([exports_external.number(), exports_external.string().min(1)]).describe("Story Estimate used when the real one is missing or cannot be converted and ifParentHasNoEstimation is 'use-default', in the Story's unit (e.g. 'M' or 5). Goes through the same conversion as a real Story Estimate.").optional()
20347
+ }).strict().superRefine((config2, ctx) => {
20348
+ if (config2.ifParentHasNoEstimation !== "use-default" || config2.defaultParentEstimation === undefined)
20349
+ return;
20350
+ if (isConvertibleStoryEstimate(config2.defaultParentEstimation, config2.conversion))
20351
+ return;
20352
+ ctx.addIssue({
20353
+ code: "custom",
20354
+ path: ["defaultParentEstimation"],
20355
+ message: config2.conversion?.table ? `defaultParentEstimation "${config2.defaultParentEstimation}" is not a key of conversion.table${config2.conversion.multipliers ? " or a multiple of one" : ""}` : `defaultParentEstimation "${config2.defaultParentEstimation}" is not numeric and no conversion.table is set`
20356
+ });
20357
+ });
20287
20358
  ValidationModeSchema = exports_external.enum(["strict", "lenient"]).describe("'strict' fails on any validation warning; 'lenient' allows warnings to pass.");
20288
20359
  ValidationConfigSchema = exports_external.object({
20289
20360
  mode: ValidationModeSchema.describe("'strict' fails on any validation warning; 'lenient' allows warnings to pass.").optional(),
@@ -20326,6 +20397,7 @@ var init_schema = __esm(() => {
20326
20397
  lastModified: exports_external.string().describe("ISO 8601 date of the last modification.").optional(),
20327
20398
  filter: FilterCriteriaSchema.describe("Criteria used to select which stories this template applies to."),
20328
20399
  tasks: exports_external.array(TaskDefinitionSchema).min(1, "At least one task is required").describe("Task definitions that will be generated from this template."),
20400
+ taskType: exports_external.string().min(1).describe("Work Item type generated Tasks are created as, in the platform's own vocabulary (e.g. 'Task', 'Sub-task'). Omit to use the platform default.").optional(),
20329
20401
  estimation: EstimationConfigSchema.describe("Global estimation configuration for all tasks in this template.").optional(),
20330
20402
  validation: ValidationConfigSchema.describe("Validation rules applied when this template is used.").optional(),
20331
20403
  metadata: MetadataSchema.describe("Additional metadata for template discovery and documentation.").optional(),
@@ -20933,6 +21005,34 @@ var init_story_batch_processor = __esm(() => {
20933
21005
  init_errors();
20934
21006
  });
20935
21007
 
21008
+ // ../atomize-core/src/core/estimation-field-mapping.ts
21009
+ function resolveEstimationFieldMapping(defaults, overrides = {}) {
21010
+ const storySource = overrides.source ? { kind: "override", field: overrides.source } : { kind: "default", fields: defaults.storyEstimateFields };
21011
+ const taskTarget = overrides.targetFields?.length ? { kind: "override", fields: [...overrides.targetFields] } : { kind: "default", fields: defaults.taskEstimateFields };
21012
+ return {
21013
+ storySource,
21014
+ taskWorkItemType: overrides.taskType ?? defaults.taskWorkItemType,
21015
+ taskTarget,
21016
+ unitLabel: taskTarget.kind === "default" ? defaults.unitLabel : undefined
21017
+ };
21018
+ }
21019
+ function readStoryEstimate(story, source) {
21020
+ const value = source.kind === "default" ? story.estimation : story.customFields?.[source.field];
21021
+ return isStoryEstimate(value) ? value : undefined;
21022
+ }
21023
+ function numericStoryEstimate(value) {
21024
+ if (typeof value === "number")
21025
+ return Number.isFinite(value) ? value : undefined;
21026
+ if (typeof value === "string" && value.trim() !== "") {
21027
+ const parsed = Number(value);
21028
+ return Number.isFinite(parsed) ? parsed : undefined;
21029
+ }
21030
+ return;
21031
+ }
21032
+ function isStoryEstimate(value) {
21033
+ return typeof value === "number" && Number.isFinite(value) || typeof value === "string" && value.trim() !== "";
21034
+ }
21035
+
20936
21036
  // ../atomize-core/src/core/condition-evaluator.ts
20937
21037
  function extractStandardFieldRefs(condition) {
20938
21038
  const refs = [];
@@ -20966,22 +21066,29 @@ function extractCustomFieldRefs(condition) {
20966
21066
  }
20967
21067
 
20968
21068
  class ConditionEvaluator {
20969
- evaluateCondition(condition, story) {
21069
+ evaluateCondition(condition, story, storyEstimateSource) {
20970
21070
  if (!condition)
20971
21071
  return true;
20972
- return this.evaluate(condition, story);
21072
+ return this.evaluate(condition, story, storyEstimateSource);
20973
21073
  }
20974
- evaluate(condition, story) {
21074
+ evaluate(condition, story, source) {
20975
21075
  if ("all" in condition) {
20976
- return condition.all.every((c2) => this.evaluate(c2, story));
21076
+ return condition.all.every((c2) => this.evaluate(c2, story, source));
20977
21077
  }
20978
21078
  if ("any" in condition) {
20979
- return condition.any.some((c2) => this.evaluate(c2, story));
21079
+ return condition.any.some((c2) => this.evaluate(c2, story, source));
20980
21080
  }
20981
21081
  if ("customField" in condition) {
20982
21082
  const fieldValue2 = story.customFields?.[condition.customField];
20983
21083
  return this.applyOperator(fieldValue2, condition.operator, condition.value);
20984
21084
  }
21085
+ if (condition.field === "estimation") {
21086
+ const estimate = readStoryEstimate(story, source ?? { kind: "default", fields: [] });
21087
+ if (estimate !== undefined && NUMERIC_OPERATORS.has(condition.operator) && numericStoryEstimate(estimate) === undefined) {
21088
+ throw new Error(`cannot compare the Story Estimate "${estimate}" with "${condition.operator}" because it is not numeric; use equals or not-equals for categories`);
21089
+ }
21090
+ return this.applyOperator(estimate, condition.operator, condition.value);
21091
+ }
20985
21092
  const fieldValue = this.resolveField(condition.field, story);
20986
21093
  return this.applyOperator(fieldValue, condition.operator, condition.value);
20987
21094
  }
@@ -21052,8 +21159,69 @@ class ConditionEvaluator {
21052
21159
  return compare(a2, b2);
21053
21160
  }
21054
21161
  }
21162
+ var NUMERIC_OPERATORS;
21055
21163
  var init_condition_evaluator = __esm(() => {
21056
21164
  init_logger();
21165
+ NUMERIC_OPERATORS = new Set(["gt", "lt", "gte", "lte"]);
21166
+ });
21167
+
21168
+ // ../atomize-core/src/templates/schema.ts
21169
+ var init_schema2 = __esm(() => {
21170
+ init_src();
21171
+ });
21172
+
21173
+ // ../atomize-core/src/core/estimation-conversion.ts
21174
+ function convertStoryEstimate(raw, conversion) {
21175
+ if (raw === undefined) {
21176
+ return { kind: "unresolvable", reason: "Story has no estimate" };
21177
+ }
21178
+ if (conversion?.table) {
21179
+ const total = lookupTableEstimate(raw, conversion.table, conversion.multipliers);
21180
+ if (total === undefined) {
21181
+ const suffix = conversion.multipliers ? " and is not a multiple of a table key" : "";
21182
+ return { kind: "unresolvable", reason: `Story Estimate "${String(raw).trim()}" is not in the conversion table${suffix}` };
21183
+ }
21184
+ return { kind: "resolved", total: roundAwayFloatNoise(total) };
21185
+ }
21186
+ const numeric = numericStoryEstimate(raw);
21187
+ if (numeric === undefined) {
21188
+ return {
21189
+ kind: "unresolvable",
21190
+ reason: `Story Estimate "${raw}" is not numeric and the Template declares no conversion for it`
21191
+ };
21192
+ }
21193
+ const factor = conversion?.factor ?? 1;
21194
+ return { kind: "resolved", total: roundAwayFloatNoise(numeric * factor) };
21195
+ }
21196
+ function roundAwayFloatNoise(value) {
21197
+ return Math.round(value * 1e9) / 1e9;
21198
+ }
21199
+ function resolveStoryEstimate(raw, config2) {
21200
+ const converted = convertStoryEstimate(raw, config2?.conversion);
21201
+ if (converted.kind === "resolved")
21202
+ return converted;
21203
+ switch (config2?.ifParentHasNoEstimation ?? "warn") {
21204
+ case "skip":
21205
+ return { kind: "skip", reason: converted.reason };
21206
+ case "use-default": {
21207
+ if (config2?.defaultParentEstimation === undefined) {
21208
+ return {
21209
+ kind: "blank",
21210
+ reason: `${converted.reason}, and no defaultParentEstimation is set`
21211
+ };
21212
+ }
21213
+ const fallback = convertStoryEstimate(config2.defaultParentEstimation, config2.conversion);
21214
+ if (fallback.kind === "resolved") {
21215
+ return { kind: "resolved", total: fallback.total, fallbackReason: converted.reason };
21216
+ }
21217
+ return { kind: "blank", reason: `${converted.reason}, and the default Story Estimate is unusable: ${fallback.reason}` };
21218
+ }
21219
+ default:
21220
+ return { kind: "blank", reason: converted.reason };
21221
+ }
21222
+ }
21223
+ var init_estimation_conversion = __esm(() => {
21224
+ init_schema2();
21057
21225
  });
21058
21226
 
21059
21227
  // ../atomize-core/src/utils/estimation-normalizer.ts
@@ -21075,7 +21243,9 @@ var init_estimation_normalizer = __esm(() => {
21075
21243
  // ../atomize-core/src/core/estimation-distribution.ts
21076
21244
  function distributeActiveTaskPercentages(tasks, options = {}) {
21077
21245
  const totalBefore = totalEstimationPercent(tasks);
21078
- const normalized = totalBefore < 100 || totalBefore > 100 && options.forceNormalize === true ? normalizeEstimationPercentages2(tasks, {
21246
+ const mode = options.mode ?? "auto";
21247
+ const shouldNormalize = mode === "always" ? totalBefore !== 100 : mode === "never" ? false : totalBefore < 100 || totalBefore > 100 && options.forceNormalize === true;
21248
+ const normalized = shouldNormalize ? normalizeEstimationPercentages2(tasks, {
21079
21249
  skipIfAlreadyNormalized: false,
21080
21250
  enableLogging: options.enableLogging ?? true
21081
21251
  }) : false;
@@ -21130,11 +21300,19 @@ class EstimationCalculator {
21130
21300
  constructor(conditionEvaluator = new ConditionEvaluator) {
21131
21301
  this.conditionEvaluator = conditionEvaluator;
21132
21302
  }
21133
- calculateTasksWithSkipped(story, connectUserEmail, templateTasks, estimationConfig, forceNormalize = false) {
21303
+ calculateTasksWithSkipped(story, connectUserEmail, templateTasks, estimationConfig, forceNormalize = false, fieldMapping) {
21134
21304
  logger.debug(`EstimationCalculator: Calculating tasks for story ${story.id}`);
21135
- const parentEstimation = story.estimation || 0;
21136
- if (parentEstimation === 0) {
21137
- logger.warn(`Story ${story.id} has no estimation. Tasks will have 0 estimation.`);
21305
+ const resolution = this.resolveParentEstimate(story, estimationConfig, fieldMapping);
21306
+ if (resolution.kind === "skip") {
21307
+ logger.warn(`Story ${story.id}: ${resolution.reason}. Skipping the Story.`);
21308
+ return {
21309
+ calculatedTasks: [],
21310
+ skippedTasks: [],
21311
+ unresolvedEstimate: { action: "skip", reason: resolution.reason }
21312
+ };
21313
+ }
21314
+ if (resolution.kind === "blank") {
21315
+ logger.warn(`Story ${story.id}: ${resolution.reason}. Task Estimates will be left blank.`);
21138
21316
  }
21139
21317
  const calculatedTasks = [];
21140
21318
  const skippedTasks = [];
@@ -21143,7 +21321,7 @@ class EstimationCalculator {
21143
21321
  if (templateTask.condition) {
21144
21322
  let conditionMet;
21145
21323
  try {
21146
- conditionMet = this.conditionEvaluator.evaluateCondition(templateTask.condition, story);
21324
+ conditionMet = this.conditionEvaluator.evaluateCondition(templateTask.condition, story, fieldMapping?.storySource);
21147
21325
  } catch (error62) {
21148
21326
  const reason = `Condition evaluation error: ${getErrorMessage(error62)}`;
21149
21327
  logger.error(`Skipping task "${templateTask.title}" - ${reason}`);
@@ -21158,25 +21336,47 @@ class EstimationCalculator {
21158
21336
  }
21159
21337
  logger.debug(`Task "${templateTask.title}" - condition met: ${JSON.stringify(templateTask.condition)}`);
21160
21338
  }
21161
- const resolvedPercent = this.resolveEffectivePercent(templateTask, story);
21339
+ let resolvedPercent;
21340
+ try {
21341
+ resolvedPercent = this.resolveEffectivePercent(templateTask, story, fieldMapping);
21342
+ } catch (error62) {
21343
+ const reason = `Condition evaluation error: ${getErrorMessage(error62)}`;
21344
+ logger.error(`Skipping task "${templateTask.title}" - ${reason}`);
21345
+ skippedTasks.push({ templateTask, reason });
21346
+ continue;
21347
+ }
21162
21348
  const calculatedTask = this.buildCalculatedTask(templateTask, story, connectUserEmail, 0, resolvedPercent);
21163
21349
  calculatedTasks.push(calculatedTask);
21164
21350
  }
21351
+ if (fieldMapping) {
21352
+ for (const task of calculatedTasks) {
21353
+ task.workItemType = fieldMapping.taskWorkItemType;
21354
+ if (fieldMapping.taskTarget.kind === "override") {
21355
+ task.estimateFields = [...fieldMapping.taskTarget.fields];
21356
+ }
21357
+ }
21358
+ }
21165
21359
  distributeActiveTaskPercentages(calculatedTasks, {
21166
21360
  forceNormalize,
21167
- enableLogging: true
21361
+ enableLogging: true,
21362
+ mode: estimationConfig?.normalize
21168
21363
  });
21169
21364
  for (const calculatedTask of calculatedTasks) {
21170
- calculatedTask.estimation = this.calculateEstimation(parentEstimation, calculatedTask, estimationConfig);
21171
- logger.debug(`Calculated task: ${calculatedTask.title} = ${calculatedTask.estimation} points (${calculatedTask.estimationPercent}%)`);
21365
+ calculatedTask.estimation = resolution.kind === "resolved" ? this.calculateEstimation(resolution.total, calculatedTask, estimationConfig) : calculatedTask.estimationFixed;
21366
+ logger.debug(`Calculated task: ${calculatedTask.title} = ${calculatedTask.estimation ?? "unestimated"} (${calculatedTask.estimationPercent}%)`);
21172
21367
  }
21173
21368
  logger.info(`EstimationCalculator: Calculated ${calculatedTasks.length} tasks, skipped ${skippedTasks.length} tasks for story ${story.id}`);
21174
- return { calculatedTasks, skippedTasks };
21369
+ return {
21370
+ calculatedTasks,
21371
+ skippedTasks,
21372
+ ...resolution.kind === "blank" ? { unresolvedEstimate: { action: "blank", reason: resolution.reason } } : {},
21373
+ ...resolution.kind === "resolved" && resolution.fallbackReason ? { defaultEstimateReason: resolution.fallbackReason } : {}
21374
+ };
21175
21375
  }
21176
- resolveEffectivePercent(task, story) {
21376
+ resolveEffectivePercent(task, story, fieldMapping) {
21177
21377
  if (task.estimationPercentCondition?.length) {
21178
21378
  for (const rule of task.estimationPercentCondition) {
21179
- if (this.conditionEvaluator.evaluateCondition(rule.condition, story)) {
21379
+ if (this.conditionEvaluator.evaluateCondition(rule.condition, story, fieldMapping?.storySource)) {
21180
21380
  logger.debug(`Task "${task.title}": conditional percent → ${rule.percent}%`);
21181
21381
  return rule.percent;
21182
21382
  }
@@ -21193,7 +21393,6 @@ class EstimationCalculator {
21193
21393
  assignTo: this.resolveAssignment(templateTask.assignTo, story, connectUserEmail),
21194
21394
  priority: templateTask.priority,
21195
21395
  activity: templateTask.activity,
21196
- completedWork: 0,
21197
21396
  iteration: story.iteration,
21198
21397
  areaPath: story.areaPath,
21199
21398
  customFields: this.interpolateCustomFields(templateTask.customFields, story),
@@ -21202,6 +21401,10 @@ class EstimationCalculator {
21202
21401
  estimationFixed: templateTask.estimationFixed
21203
21402
  };
21204
21403
  }
21404
+ resolveParentEstimate(story, config2, fieldMapping) {
21405
+ const raw = readStoryEstimate(story, fieldMapping?.storySource ?? { kind: "default", fields: [] });
21406
+ return resolveStoryEstimate(raw, config2);
21407
+ }
21205
21408
  calculateEstimation(parentEstimation, task, config2) {
21206
21409
  if (task.estimationFixed !== undefined) {
21207
21410
  return task.estimationFixed;
@@ -21209,7 +21412,7 @@ class EstimationCalculator {
21209
21412
  if (task.estimationPercent !== undefined) {
21210
21413
  const rawEstimation = parentEstimation * task.estimationPercent / 100;
21211
21414
  const rounded = this.roundEstimation(rawEstimation, config2?.rounding || "none");
21212
- const minimum = config2?.minimumTaskPoints || 0;
21415
+ const minimum = config2?.minimumTaskEstimate ?? config2?.minimumTaskPoints ?? 0;
21213
21416
  return Math.max(rounded, minimum);
21214
21417
  }
21215
21418
  if (task.estimationFormula) {
@@ -21252,8 +21455,9 @@ class EstimationCalculator {
21252
21455
  calculateTotalEstimation(tasks) {
21253
21456
  return tasks.reduce((sum, task) => sum + (task.estimation || 0), 0);
21254
21457
  }
21255
- getEstimationSummary(story, tasks) {
21256
- const storyEstimation = story.estimation || 0;
21458
+ getEstimationSummary(story, tasks, config2, fieldMapping) {
21459
+ const resolution = this.resolveParentEstimate(story, config2, fieldMapping);
21460
+ const storyEstimation = resolution.kind === "resolved" ? resolution.total : 0;
21257
21461
  const totalTaskEstimation = this.calculateTotalEstimation(tasks);
21258
21462
  const difference = storyEstimation - totalTaskEstimation;
21259
21463
  const percentageUsed = storyEstimation > 0 ? totalTaskEstimation / storyEstimation * 100 : 0;
@@ -21264,13 +21468,16 @@ class EstimationCalculator {
21264
21468
  percentageUsed
21265
21469
  };
21266
21470
  }
21267
- validateEstimation(story, tasks) {
21471
+ validateEstimation(story, tasks, config2, fieldMapping) {
21268
21472
  const warnings = [];
21269
- const summary = this.getEstimationSummary(story, tasks);
21473
+ if (this.resolveParentEstimate(story, config2, fieldMapping).kind !== "resolved") {
21474
+ return { valid: true, warnings };
21475
+ }
21476
+ const summary = this.getEstimationSummary(story, tasks, config2, fieldMapping);
21270
21477
  if (Math.abs(summary.difference) > 0.5) {
21271
21478
  warnings.push(`Total task estimation (${summary.totalTaskEstimation}) differs from story estimation (${summary.storyEstimation}) by ${summary.difference}`);
21272
21479
  }
21273
- const zeroEstimations = tasks.filter((t2) => (t2.estimation || 0) === 0);
21480
+ const zeroEstimations = tasks.filter((t2) => t2.estimation === 0);
21274
21481
  if (zeroEstimations.length > 0) {
21275
21482
  warnings.push(`${zeroEstimations.length} task(s) have zero estimation: ${zeroEstimations.map((t2) => t2.title).join(", ")}`);
21276
21483
  }
@@ -21285,6 +21492,7 @@ var init_estimation_calculator = __esm(() => {
21285
21492
  init_logger();
21286
21493
  init_errors();
21287
21494
  init_condition_evaluator();
21495
+ init_estimation_conversion();
21288
21496
  init_estimation_distribution();
21289
21497
  });
21290
21498
 
@@ -21361,27 +21569,54 @@ var init_task_materializer = __esm(() => {
21361
21569
  class StoryProcessor {
21362
21570
  estimationCalculator;
21363
21571
  taskMaterializer;
21572
+ estimationDefaults;
21364
21573
  constructor(platform, estimationCalculator = new EstimationCalculator, taskMaterializer = new TaskMaterializer(platform)) {
21365
21574
  this.estimationCalculator = estimationCalculator;
21366
21575
  this.taskMaterializer = taskMaterializer;
21576
+ this.estimationDefaults = platform.getEstimationDefaults?.();
21367
21577
  }
21368
21578
  async process(story, orderedTasks, template, connectUserEmail, options, warnings) {
21369
21579
  logger.info(`Processing: ${story.id} - ${story.title}`);
21370
- const { calculatedTasks, skippedTasks } = this.estimationCalculator.calculateTasksWithSkipped(story, connectUserEmail, orderedTasks, template.estimation, options.forceNormalize);
21580
+ const fieldMapping = this.estimationDefaults ? resolveEstimationFieldMapping(this.estimationDefaults, {
21581
+ source: template.estimation?.source,
21582
+ taskType: template.taskType,
21583
+ targetFields: template.estimation?.targetFields
21584
+ }) : undefined;
21585
+ const { calculatedTasks, skippedTasks, unresolvedEstimate, defaultEstimateReason } = this.estimationCalculator.calculateTasksWithSkipped(story, connectUserEmail, orderedTasks, template.estimation, options.forceNormalize, fieldMapping);
21371
21586
  logger.info(`Generated ${calculatedTasks.length} tasks, skipped ${skippedTasks.length} conditional tasks`);
21587
+ if (unresolvedEstimate?.action === "skip") {
21588
+ const skipReason = `${unresolvedEstimate.reason}; Story skipped (ifParentHasNoEstimation: skip)`;
21589
+ warnings.push(`${story.id}: ${skipReason}`);
21590
+ return {
21591
+ story,
21592
+ tasksCalculated: [],
21593
+ tasksCreated: [],
21594
+ tasksSkipped: [],
21595
+ success: true,
21596
+ ...fieldMapping?.unitLabel ? { estimateUnit: fieldMapping.unitLabel } : {},
21597
+ ...withStoryEstimate(readStoryEstimate(story, fieldMapping?.storySource ?? { kind: "default", fields: [] })),
21598
+ skipReason
21599
+ };
21600
+ }
21601
+ if (unresolvedEstimate?.action === "blank") {
21602
+ warnings.push(`${story.id}: ${unresolvedEstimate.reason}; Task Estimates left blank`);
21603
+ }
21604
+ if (defaultEstimateReason) {
21605
+ warnings.push(`${story.id}: ${defaultEstimateReason}; used defaultParentEstimation`);
21606
+ }
21372
21607
  if (skippedTasks.length > 0) {
21373
21608
  skippedTasks.forEach((skipped) => {
21374
21609
  logger.debug(` Skipped: "${skipped.templateTask.title}" - ${skipped.reason}`);
21375
21610
  });
21376
21611
  }
21377
- const validation = this.estimationCalculator.validateEstimation(story, calculatedTasks);
21612
+ const validation = this.estimationCalculator.validateEstimation(story, calculatedTasks, template.estimation, fieldMapping);
21378
21613
  if (!validation.valid) {
21379
21614
  validation.warnings.forEach((warning) => {
21380
21615
  logger.warn(`${warning}`);
21381
21616
  warnings.push(`${story.id}: ${warning}`);
21382
21617
  });
21383
21618
  }
21384
- const estimationSummary = this.estimationCalculator.getEstimationSummary(story, calculatedTasks);
21619
+ const estimationSummary = this.estimationCalculator.getEstimationSummary(story, calculatedTasks, template.estimation, fieldMapping);
21385
21620
  logger.debug("Estimation summary:", estimationSummary);
21386
21621
  let tasksCreated = [];
21387
21622
  if (options.dryRun) {
@@ -21397,10 +21632,15 @@ class StoryProcessor {
21397
21632
  tasksCreated,
21398
21633
  tasksSkipped: skippedTasks,
21399
21634
  success: true,
21635
+ ...fieldMapping?.unitLabel ? { estimateUnit: fieldMapping.unitLabel } : {},
21636
+ ...withStoryEstimate(readStoryEstimate(story, fieldMapping?.storySource ?? { kind: "default", fields: [] })),
21400
21637
  estimationSummary
21401
21638
  };
21402
21639
  }
21403
21640
  }
21641
+ function withStoryEstimate(estimate) {
21642
+ return estimate === undefined ? {} : { storyEstimate: estimate };
21643
+ }
21404
21644
  var init_story_processor = __esm(() => {
21405
21645
  init_logger();
21406
21646
  init_estimation_calculator();
@@ -79871,29 +80111,33 @@ class FieldSchemaService {
79871
80111
  const allowedValues = witField.allowedValues && witField.allowedValues.length > 0 ? witField.allowedValues : undefined;
79872
80112
  const isPicklist = !!(allowedValues ?? meta3?.isPicklist);
79873
80113
  const isIdentity = meta3?.isIdentity === true || fieldType === import_WorkItemTrackingInterfaces.FieldType.Identity;
80114
+ const type = isIdentity ? "identity" : this.mapFieldType(fieldType);
79874
80115
  return {
79875
80116
  referenceName,
79876
80117
  name: witField.name ?? meta3?.name ?? referenceName,
79877
- type: isIdentity ? "identity" : this.mapFieldType(fieldType),
80118
+ type,
79878
80119
  isCustom: referenceName.startsWith("Custom."),
79879
80120
  isReadOnly: meta3?.readOnly ?? false,
79880
80121
  isMultiline: !isPicklist && (fieldType === import_WorkItemTrackingInterfaces.FieldType.Html || fieldType === import_WorkItemTrackingInterfaces.FieldType.PlainText),
79881
80122
  isPicklist,
79882
- allowedValues
80123
+ allowedValues,
80124
+ allowsCustomValues: isPicklist ? meta3?.isPicklistSuggested === true : type === "string"
79883
80125
  };
79884
80126
  }
79885
80127
  mapWorkItemField(f) {
79886
80128
  const referenceName = f.referenceName ?? "";
79887
80129
  const isIdentity = f.isIdentity === true || f.type === import_WorkItemTrackingInterfaces.FieldType.Identity;
80130
+ const type = isIdentity ? "identity" : this.mapFieldType(f.type);
79888
80131
  return {
79889
80132
  referenceName,
79890
80133
  name: f.name ?? referenceName,
79891
- type: isIdentity ? "identity" : this.mapFieldType(f.type),
80134
+ type,
79892
80135
  isCustom: referenceName.startsWith("Custom."),
79893
80136
  isReadOnly: f.readOnly ?? false,
79894
80137
  isMultiline: !f.isPicklist && (f.type === import_WorkItemTrackingInterfaces.FieldType.Html || f.type === import_WorkItemTrackingInterfaces.FieldType.PlainText),
79895
80138
  isPicklist: f.isPicklist ?? false,
79896
- allowedValues: undefined
80139
+ allowedValues: undefined,
80140
+ allowsCustomValues: f.isPicklist ? f.isPicklistSuggested === true : type === "string"
79897
80141
  };
79898
80142
  }
79899
80143
  mapFieldType(fieldType) {
@@ -79920,6 +80164,33 @@ var init_azure_devops_field_schema_service = __esm(() => {
79920
80164
  import_WorkItemTrackingInterfaces = __toESM(require_WorkItemTrackingInterfaces(), 1);
79921
80165
  });
79922
80166
 
80167
+ // ../atomize-core/src/platforms/adapters/azure-devops/estimation-defaults.ts
80168
+ var AZURE_DEVOPS_ESTIMATION_DEFAULTS;
80169
+ var init_estimation_defaults = __esm(() => {
80170
+ AZURE_DEVOPS_ESTIMATION_DEFAULTS = {
80171
+ storyEstimateFields: [
80172
+ "Microsoft.VSTS.Scheduling.StoryPoints",
80173
+ "Microsoft.VSTS.Scheduling.Effort",
80174
+ "Microsoft.VSTS.Scheduling.Size",
80175
+ "Microsoft.VSTS.Scheduling.OriginalEstimate"
80176
+ ],
80177
+ taskWorkItemType: "Task",
80178
+ taskEstimateFields: [
80179
+ "Microsoft.VSTS.Scheduling.RemainingWork",
80180
+ "Microsoft.VSTS.Scheduling.OriginalEstimate"
80181
+ ],
80182
+ unitLabel: "hours",
80183
+ nonEstimateFields: [
80184
+ "Microsoft.VSTS.Common.*",
80185
+ "Microsoft.VSTS.Build.*",
80186
+ "Microsoft.VSTS.CMMI.*",
80187
+ "Microsoft.VSTS.CodeReview.*",
80188
+ "Microsoft.VSTS.Feedback.*",
80189
+ "Microsoft.VSTS.TCM.*"
80190
+ ]
80191
+ };
80192
+ });
80193
+
79923
80194
  // ../atomize-core/src/platforms/adapters/azure-devops/task-patch-builder.ts
79924
80195
  function resolveDateMacro(value) {
79925
80196
  if (typeof value !== "string")
@@ -79951,7 +80222,7 @@ function resolveDateMacro(value) {
79951
80222
  base.setDate(base.getDate() + offsetDays);
79952
80223
  return base.toISOString().slice(0, 10);
79953
80224
  }
79954
- function buildCreateTaskPatch(organizationUrl, parentId, task) {
80225
+ function buildCreateTaskPatch(organizationUrl, parentId, task, estimateWrite) {
79955
80226
  return [
79956
80227
  {
79957
80228
  op: "add",
@@ -79965,25 +80236,12 @@ function buildCreateTaskPatch(organizationUrl, parentId, task) {
79965
80236
  value: task.description
79966
80237
  }
79967
80238
  ] : [],
79968
- ...task.estimation !== undefined ? [
79969
- {
79970
- op: "add",
79971
- path: "/fields/Microsoft.VSTS.Scheduling.RemainingWork",
79972
- value: task.estimation
79973
- },
79974
- {
79975
- op: "add",
79976
- path: "/fields/Microsoft.VSTS.Scheduling.OriginalEstimate",
79977
- value: task.estimation
79978
- }
79979
- ] : [],
79980
- ...task.completedWork !== undefined ? [
79981
- {
79982
- op: "add",
79983
- path: "/fields/Microsoft.VSTS.Scheduling.CompletedWork",
79984
- value: task.completedWork
79985
- }
79986
- ] : [],
80239
+ ...task.estimation !== undefined ? estimateWrite.fields.map((field) => ({
80240
+ op: "add",
80241
+ path: `/fields/${field}`,
80242
+ value: task.estimation
80243
+ })) : [],
80244
+ ...estimateWrite.initializeCompletedWork ? [{ op: "add", path: "/fields/Microsoft.VSTS.Scheduling.CompletedWork", value: 0 }] : [],
79987
80245
  ...task.iteration ? [
79988
80246
  {
79989
80247
  op: "add",
@@ -80065,6 +80323,14 @@ var init_task_patch_builder = __esm(() => {
80065
80323
  });
80066
80324
 
80067
80325
  // ../atomize-core/src/platforms/adapters/azure-devops/work-item-mapper.ts
80326
+ function firstEstimate(fields) {
80327
+ for (const field of AZURE_DEVOPS_ESTIMATION_DEFAULTS.storyEstimateFields) {
80328
+ const value = fields[field];
80329
+ if ((typeof value === "number" || typeof value === "string") && value)
80330
+ return value;
80331
+ }
80332
+ return;
80333
+ }
80068
80334
  function convertWorkItem(azureItem) {
80069
80335
  const fields = azureItem.fields || {};
80070
80336
  const predecessorIds = [];
@@ -80091,7 +80357,7 @@ function convertWorkItem(azureItem) {
80091
80357
  type: fields["System.WorkItemType"],
80092
80358
  state: fields["System.State"] || "",
80093
80359
  assignedTo: fields["System.AssignedTo"]?.uniqueName || fields["System.AssignedTo"]?.displayName,
80094
- estimation: fields["Microsoft.VSTS.Scheduling.StoryPoints"] || fields["Microsoft.VSTS.Scheduling.OriginalEstimate"],
80360
+ estimation: firstEstimate(fields),
80095
80361
  tags: fields["System.Tags"] ? fields["System.Tags"].split("; ") : [],
80096
80362
  description: fields["System.Description"],
80097
80363
  areaPath: fields["System.AreaPath"],
@@ -80115,10 +80381,8 @@ function hasChildRelations(workItem) {
80115
80381
  }
80116
80382
  return workItem.relations.some((rel) => rel.rel === "System.LinkTypes.Hierarchy-Forward");
80117
80383
  }
80118
-
80119
- // ../atomize-core/src/templates/schema.ts
80120
- var init_schema2 = __esm(() => {
80121
- init_src();
80384
+ var init_work_item_mapper = __esm(() => {
80385
+ init_estimation_defaults();
80122
80386
  });
80123
80387
 
80124
80388
  // ../atomize-core/src/platforms/adapters/azure-devops/work-item-query.ts
@@ -80383,6 +80647,7 @@ var import_WorkItemTrackingInterfaces2;
80383
80647
  var init_work_item_retrieval = __esm(() => {
80384
80648
  init_logger();
80385
80649
  init_errors();
80650
+ init_work_item_mapper();
80386
80651
  init_work_item_query();
80387
80652
  import_WorkItemTrackingInterfaces2 = __toESM(require_WorkItemTrackingInterfaces(), 1);
80388
80653
  });
@@ -80490,11 +80755,13 @@ class AzureDevOpsAdapter {
80490
80755
  if (Number.isNaN(numericParentId)) {
80491
80756
  throw new Error(`Invalid parent ID: ${parentId}`);
80492
80757
  }
80493
- const patchDocument = buildCreateTaskPatch(this.config.organizationUrl, numericParentId, task);
80758
+ const defaults = this.getEstimationDefaults();
80759
+ const estimateWrite = task.estimateFields?.length ? { fields: task.estimateFields } : { fields: defaults.taskEstimateFields, initializeCompletedWork: true };
80760
+ const patchDocument = buildCreateTaskPatch(this.config.organizationUrl, numericParentId, task, estimateWrite);
80494
80761
  if (!this.witApi) {
80495
80762
  throw new UnknownError("Work Item Tracking API not initialized");
80496
80763
  }
80497
- const createdItem = await this.witApi.createWorkItem(undefined, patchDocument, this.config.project, "Task");
80764
+ const createdItem = await this.witApi.createWorkItem(undefined, patchDocument, this.config.project, task.workItemType ?? defaults.taskWorkItemType);
80498
80765
  logger.info(`AzureDevOps: Created task ${createdItem.id}: ${task.title}`);
80499
80766
  return convertWorkItem(createdItem);
80500
80767
  } catch (error62) {
@@ -80531,6 +80798,9 @@ class AzureDevOpsAdapter {
80531
80798
  logger.info(`AzureDevOps: Created ${createdTasks.length} of ${tasks.length} tasks`);
80532
80799
  return createdTasks;
80533
80800
  }
80801
+ getEstimationDefaults() {
80802
+ return AZURE_DEVOPS_ESTIMATION_DEFAULTS;
80803
+ }
80534
80804
  getPlatformMetadata() {
80535
80805
  return {
80536
80806
  name: "Azure DevOps",
@@ -80743,7 +81013,9 @@ var init_azure_devops_adapter = __esm(() => {
80743
81013
  init_logger();
80744
81014
  init_errors();
80745
81015
  init_azure_devops_field_schema_service();
81016
+ init_estimation_defaults();
80746
81017
  init_task_patch_builder();
81018
+ init_work_item_mapper();
80747
81019
  init_work_item_query();
80748
81020
  init_work_item_retrieval();
80749
81021
  azdev = __toESM(require_WebApi(), 1);
@@ -80995,7 +81267,7 @@ class MockPlatformAdapter {
80995
81267
  const createdTask = {
80996
81268
  id: `TASK-${this.taskIdCounter++}`,
80997
81269
  title: task.title,
80998
- type: "Task",
81270
+ type: task.workItemType ?? MOCK_ESTIMATION_DEFAULTS.taskWorkItemType,
80999
81271
  state: "New",
81000
81272
  assignedTo: task.assignTo,
81001
81273
  estimation: task.estimation,
@@ -81063,6 +81335,9 @@ class MockPlatformAdapter {
81063
81335
  logger.info(`MockPlatform: Deleted work item ${id}`);
81064
81336
  return true;
81065
81337
  }
81338
+ getEstimationDefaults() {
81339
+ return MOCK_ESTIMATION_DEFAULTS;
81340
+ }
81066
81341
  getPlatformMetadata() {
81067
81342
  return {
81068
81343
  name: "Mock Platform",
@@ -81109,9 +81384,16 @@ class MockPlatformAdapter {
81109
81384
  return new Promise((resolve) => setTimeout(resolve, ms));
81110
81385
  }
81111
81386
  }
81387
+ var MOCK_ESTIMATION_DEFAULTS;
81112
81388
  var init_mock_adapter = __esm(() => {
81113
81389
  init_logger();
81114
81390
  init_mock_data();
81391
+ MOCK_ESTIMATION_DEFAULTS = {
81392
+ storyEstimateFields: ["estimation"],
81393
+ taskWorkItemType: "Task",
81394
+ taskEstimateFields: ["estimation"],
81395
+ unitLabel: "hours"
81396
+ };
81115
81397
  });
81116
81398
 
81117
81399
  // ../atomize-core/src/platforms/platform-factory.ts
@@ -88764,6 +89046,7 @@ function mergeTemplates(base, child) {
88764
89046
  lastModified: child.lastModified ?? base.lastModified,
88765
89047
  filter: child.filter ?? base.filter,
88766
89048
  tasks: mergeTasks(base.tasks, child.tasks ?? []),
89049
+ taskType: child.taskType ?? base.taskType,
88767
89050
  estimation: child.estimation ?? base.estimation,
88768
89051
  validation: child.validation ?? base.validation,
88769
89052
  metadata: child.metadata ?? base.metadata
@@ -89197,10 +89480,11 @@ class TemplateValidator {
89197
89480
  const v2 = template.validation;
89198
89481
  const total = template.tasks.reduce((sum, t2) => sum + (t2.estimationPercent ?? 0), 0);
89199
89482
  const hasStrictRule = v2?.totalEstimationMustBe !== undefined || v2?.totalEstimationRange !== undefined;
89200
- if (!hasStrictRule && total > 100) {
89483
+ const normalize = template.estimation?.normalize ?? "auto";
89484
+ if (!hasStrictRule && total > 100 && normalize !== "always") {
89201
89485
  warnings.push({
89202
89486
  path: "tasks",
89203
- message: `Total estimation is ${total}% (exceeds 100%). This is valid when tasks span multiple roles. You will be prompted to normalise at generate time.`,
89487
+ message: normalize === "never" ? `Total estimation is ${total}% (exceeds 100%). With normalize: never, tasks are generated with these percentages as written.` : `Total estimation is ${total}% (exceeds 100%). This is valid when tasks span multiple roles. You will be prompted to normalise at generate time.`,
89204
89488
  nonBlocking: true
89205
89489
  });
89206
89490
  }
@@ -89215,8 +89499,31 @@ class TemplateValidator {
89215
89499
  this.validateTaskConditions(template, warnings);
89216
89500
  this.validateTaskDependencies(template, warnings);
89217
89501
  this.validateSavedQueryConflict(template, warnings);
89502
+ this.validateDeprecatedMinimumTaskPoints(template, warnings);
89218
89503
  return warnings;
89219
89504
  }
89505
+ validateDeprecatedMinimumTaskPoints(template, warnings) {
89506
+ const estimation = template.estimation;
89507
+ if (estimation?.minimumTaskPoints === undefined)
89508
+ return;
89509
+ if (estimation.minimumTaskEstimate === undefined) {
89510
+ warnings.push({
89511
+ path: "estimation.minimumTaskPoints",
89512
+ message: "minimumTaskPoints is deprecated; the minimum applies in the Task's unit, which may not be points.",
89513
+ suggestion: `Rename it to minimumTaskEstimate: ${estimation.minimumTaskPoints}.`,
89514
+ code: FixableWarningCode.DEPRECATED_MINIMUM_TASK_POINTS,
89515
+ nonBlocking: true
89516
+ });
89517
+ return;
89518
+ }
89519
+ warnings.push({
89520
+ path: "estimation.minimumTaskPoints",
89521
+ message: `Both minimumTaskEstimate and the deprecated minimumTaskPoints are set; minimumTaskEstimate (${estimation.minimumTaskEstimate}) is used and minimumTaskPoints (${estimation.minimumTaskPoints}) is ignored.`,
89522
+ suggestion: "Remove minimumTaskPoints.",
89523
+ code: FixableWarningCode.DUPLICATE_MINIMUM_TASK_ESTIMATE,
89524
+ nonBlocking: true
89525
+ });
89526
+ }
89220
89527
  collectMixinWarnings(mixin) {
89221
89528
  const warnings = [];
89222
89529
  this.validateTaskDependencies(mixin, warnings);
@@ -89266,13 +89573,13 @@ class TemplateValidator {
89266
89573
  const f = template.filter;
89267
89574
  if (!f.savedQuery)
89268
89575
  return;
89269
- const hasStructuredFields = f.workItemTypes || f.states || f.statesExclude || f.statesWereEver || f.tags?.include || f.tags?.exclude || f.areaPaths || f.areaPathsUnder || f.iterations || f.iterationsUnder || f.assignedTo || f.changedAfter || f.createdAfter || f.priority;
89576
+ const hasStructuredFields = f.states || f.statesExclude || f.statesWereEver || f.tags?.include || f.tags?.exclude || f.areaPaths || f.areaPathsUnder || f.iterations || f.iterationsUnder || f.assignedTo || f.changedAfter || f.createdAfter || f.priority;
89270
89577
  if (hasStructuredFields) {
89271
89578
  warnings.push({
89272
89579
  path: "filter.savedQuery",
89273
89580
  message: "savedQuery and structured filter fields are both set. Structured filter fields will be ignored — the saved query controls which items are returned.",
89274
89581
  code: FixableWarningCode.SAVED_QUERY_WITH_STRUCTURED_FILTER,
89275
- suggestion: "Remove workItemTypes, states, tags, etc. from the filter when using savedQuery."
89582
+ suggestion: "Remove states, tags, area paths, etc. from the filter when using savedQuery. Keep workItemTypes: it declares which types the query returns."
89276
89583
  });
89277
89584
  }
89278
89585
  }
@@ -89417,15 +89724,18 @@ var init_validator = __esm(() => {
89417
89724
  FixableWarningCode = {
89418
89725
  MISSING_TASK_ID: "MISSING_TASK_ID",
89419
89726
  SAVED_QUERY_WITH_STRUCTURED_FILTER: "SAVED_QUERY_WITH_STRUCTURED_FILTER",
89420
- SINGLE_LINE_FIELD_WITH_NEWLINES: "SINGLE_LINE_FIELD_WITH_NEWLINES"
89727
+ SINGLE_LINE_FIELD_WITH_NEWLINES: "SINGLE_LINE_FIELD_WITH_NEWLINES",
89728
+ DEPRECATED_MINIMUM_TASK_POINTS: "DEPRECATED_MINIMUM_TASK_POINTS",
89729
+ DUPLICATE_MINIMUM_TASK_ESTIMATE: "DUPLICATE_MINIMUM_TASK_ESTIMATE"
89421
89730
  };
89422
89731
  });
89423
89732
 
89424
89733
  // ../atomize-core/src/templates/custom-field-verifier.ts
89425
- async function verifyTemplateCustomFields(template, getSchemas) {
89734
+ async function verifyTemplateCustomFields(template, getSchemas, taskWorkItemType) {
89426
89735
  const errors4 = [];
89427
89736
  const warnings = [];
89428
- const taskSchemas = await getSchemas("Task");
89737
+ const taskSchemas = await getSchemas(taskWorkItemType);
89738
+ const scope = taskWorkItemType ? `work item type "${taskWorkItemType}"` : "this project";
89429
89739
  const schemaByRef = new Map(taskSchemas.map((field) => [field.referenceName, field]));
89430
89740
  for (let i2 = 0;i2 < template.tasks.length; i2++) {
89431
89741
  const task = template.tasks[i2];
@@ -89437,7 +89747,7 @@ async function verifyTemplateCustomFields(template, getSchemas) {
89437
89747
  if (!schema2) {
89438
89748
  errors4.push({
89439
89749
  path,
89440
- message: `Field "${refName}" not found for work item type "Task".`,
89750
+ message: `Field "${refName}" not found for ${scope}.`,
89441
89751
  code: "CUSTOM_FIELD_NOT_FOUND"
89442
89752
  });
89443
89753
  continue;
@@ -89589,16 +89899,166 @@ var init_custom_field_verifier = __esm(() => {
89589
89899
  init_validator();
89590
89900
  });
89591
89901
 
89902
+ // ../atomize-core/src/templates/estimation-verifier.ts
89903
+ function hasEstimationMappingOverrides(template) {
89904
+ return !!(template.taskType || template.estimation?.source || template.estimation?.targetFields?.length);
89905
+ }
89906
+ async function verifyEstimationMapping(template, platform) {
89907
+ const errors4 = [];
89908
+ const warnings = [];
89909
+ if (!hasEstimationMappingOverrides(template))
89910
+ return { errors: errors4, warnings };
89911
+ const taskType = template.taskType ?? platform.getEstimationDefaults?.().taskWorkItemType;
89912
+ const nonEstimateFields = platform.getEstimationDefaults?.().nonEstimateFields ?? [];
89913
+ const taskTypeExists = await verifyTaskType(template, platform, errors4);
89914
+ const targetFields = template.estimation?.targetFields ?? [];
89915
+ if (targetFields.length > 0 && taskType && taskTypeExists) {
89916
+ const taskFields = byReference(await platform.getFieldSchemas(taskType));
89917
+ targetFields.forEach((ref, i2) => {
89918
+ const path = `estimation.targetFields[${i2}]`;
89919
+ const field = taskFields.get(ref);
89920
+ if (!field) {
89921
+ errors4.push({ path, message: `Field "${ref}" not found for work item type "${taskType}".`, code: "ESTIMATION_TARGET_FIELD_NOT_FOUND" });
89922
+ } else if (!NUMERIC_TYPES.has(field.type)) {
89923
+ errors4.push({
89924
+ path,
89925
+ message: `Field "${ref}" is a ${field.type} field; Task Estimates can only be written to numeric fields.`,
89926
+ code: "ESTIMATION_TARGET_FIELD_NOT_NUMERIC"
89927
+ });
89928
+ } else if (field.isPicklist) {
89929
+ errors4.push({
89930
+ path,
89931
+ message: `Field "${ref}" only accepts a fixed list of values, so calculated Task Estimates can't be written to it.`,
89932
+ code: "ESTIMATION_TARGET_FIELD_PICKLIST"
89933
+ });
89934
+ } else if (matchesFieldPattern(ref, nonEstimateFields)) {
89935
+ warnings.push({
89936
+ path,
89937
+ message: `Field "${ref}" holds a ranking or value, not effort; writing Task Estimates to it will overwrite that.`,
89938
+ suggestion: "Choose an effort field such as the platform's default estimate fields or a custom effort field."
89939
+ });
89940
+ }
89941
+ });
89942
+ }
89943
+ const source = template.estimation?.source;
89944
+ if (source) {
89945
+ const sourceField = await verifySourceField(template, source, platform, errors4);
89946
+ if (sourceField)
89947
+ checkSourceValues(template, sourceField, warnings);
89948
+ }
89949
+ return { errors: errors4, warnings };
89950
+ }
89951
+ async function verifyTaskType(template, platform, errors4) {
89952
+ if (!template.taskType || !platform.getWorkItemTypes)
89953
+ return true;
89954
+ const types = await platform.getWorkItemTypes();
89955
+ if (types.includes(template.taskType))
89956
+ return true;
89957
+ errors4.push({
89958
+ path: "taskType",
89959
+ message: `Work item type "${template.taskType}" does not exist in this project. Available types: ${types.join(", ")}.`,
89960
+ code: "ESTIMATION_TASK_TYPE_NOT_FOUND"
89961
+ });
89962
+ return false;
89963
+ }
89964
+ async function verifySourceField(template, source, platform, errors4) {
89965
+ const storyTypes = template.filter.workItemTypes?.length ? template.filter.workItemTypes : [undefined];
89966
+ let found;
89967
+ for (const storyType of storyTypes) {
89968
+ const field = byReference(await platform.getFieldSchemas(storyType)).get(source);
89969
+ if (field) {
89970
+ found ??= field;
89971
+ continue;
89972
+ }
89973
+ errors4.push({
89974
+ path: "estimation.source",
89975
+ message: storyType ? `Field "${source}" not found for work item type "${storyType}".` : `Field "${source}" not found in this project.`,
89976
+ code: "ESTIMATION_SOURCE_NOT_FOUND"
89977
+ });
89978
+ }
89979
+ return found;
89980
+ }
89981
+ function checkSourceValues(template, field, warnings) {
89982
+ const table = template.estimation?.conversion?.table;
89983
+ const multipliers = template.estimation?.conversion?.multipliers === true;
89984
+ const allowed = field.isPicklist ? field.allowedValues ?? [] : [];
89985
+ if (table && allowed.length > 0) {
89986
+ const keys = Object.keys(table);
89987
+ const uncovered = allowed.filter((value) => lookupTableEstimate(value, table, multipliers) === undefined);
89988
+ if (uncovered.length > 0) {
89989
+ warnings.push({
89990
+ path: "estimation.conversion.table",
89991
+ message: `${listValues(uncovered)} ${uncovered.length === 1 ? "is a valid value" : "are valid values"} of "${field.referenceName}" but ${uncovered.length === 1 ? "has" : "have"} no conversion; Stories with ${uncovered.length === 1 ? "it" : "them"} will get blank Task Estimates.`,
89992
+ suggestion: "Add the missing values to conversion.table."
89993
+ });
89994
+ }
89995
+ const usedThroughMultiplier = (key) => multipliers && allowed.some((value) => value !== key && lookupTableEstimate(value, { [key]: 1 }, true) !== undefined);
89996
+ const unknown2 = keys.filter((key) => !allowed.includes(key) && !usedThroughMultiplier(key));
89997
+ if (unknown2.length > 0) {
89998
+ warnings.push({
89999
+ path: "estimation.conversion.table",
90000
+ message: `conversion.table ${unknown2.length === 1 ? "key" : "keys"} ${listValues(unknown2)} ${unknown2.length === 1 ? "is not an allowed value" : "are not allowed values"} of "${field.referenceName}" and will never match.`,
90001
+ suggestion: `Allowed values: ${listValues(allowed)}.`
90002
+ });
90003
+ }
90004
+ }
90005
+ if (field.type === "string" && field.allowsCustomValues) {
90006
+ const consequence = multipliers ? "any value that neither matches a conversion table key nor parses as a number followed by a key will leave Tasks unestimated." : "any value not in the conversion table will leave Tasks unestimated.";
90007
+ warnings.push({
90008
+ path: "estimation.source",
90009
+ message: field.isPicklist ? `"${field.referenceName}" is a suggested picklist that also accepts values outside its list; ${consequence}` : `"${field.referenceName}" is a free-text field; ${consequence}`
90010
+ });
90011
+ }
90012
+ if (field.type === "string") {
90013
+ template.tasks.forEach((task, i2) => {
90014
+ const conditions = [
90015
+ [`tasks[${i2}].condition`, task.condition],
90016
+ ...(task.estimationPercentCondition ?? []).map((rule, j2) => [`tasks[${i2}].estimationPercentCondition[${j2}].condition`, rule.condition])
90017
+ ];
90018
+ for (const [path, condition] of conditions) {
90019
+ if (condition && usesNumericOperatorOnEstimation(condition)) {
90020
+ warnings.push({
90021
+ path,
90022
+ message: `This condition compares estimation numerically, but "${field.referenceName}" holds text values; it will skip the task for any non-numeric Story Estimate.`,
90023
+ suggestion: "Use equals or not-equals with the category values instead."
90024
+ });
90025
+ }
90026
+ }
90027
+ });
90028
+ }
90029
+ }
90030
+ function usesNumericOperatorOnEstimation(condition) {
90031
+ if ("all" in condition)
90032
+ return condition.all.some(usesNumericOperatorOnEstimation);
90033
+ if ("any" in condition)
90034
+ return condition.any.some(usesNumericOperatorOnEstimation);
90035
+ return "field" in condition && condition.field === "estimation" && NUMERIC_OPERATORS2.has(condition.operator);
90036
+ }
90037
+ function byReference(fields) {
90038
+ return new Map(fields.map((field) => [field.referenceName, field]));
90039
+ }
90040
+ function listValues(values) {
90041
+ return values.map((value) => `"${value}"`).join(", ");
90042
+ }
90043
+ var NUMERIC_TYPES, NUMERIC_OPERATORS2;
90044
+ var init_estimation_verifier = __esm(() => {
90045
+ init_schema2();
90046
+ NUMERIC_TYPES = new Set(["integer", "decimal"]);
90047
+ NUMERIC_OPERATORS2 = new Set(["gt", "lt", "gte", "lte"]);
90048
+ });
90049
+
89592
90050
  // ../atomize-core/src/templates/project-verifier.ts
89593
90051
  function analyzeTemplateProjectVerification(template) {
89594
90052
  const customFieldTaskCount = template.tasks.filter((task) => task.customFields && Object.keys(task.customFields).length > 0).length;
89595
90053
  const conditionFieldRefs = Array.from(new Set(template.tasks.flatMap((task) => task.condition ? extractCustomFieldRefs(task.condition) : [])));
89596
90054
  const hasSavedQuery = !!(template.filter?.savedQuery?.id || template.filter?.savedQuery?.path);
90055
+ const hasEstimationMapping = hasEstimationMappingOverrides(template);
89597
90056
  return {
89598
90057
  customFieldTaskCount,
89599
90058
  conditionFieldRefs,
89600
90059
  hasSavedQuery,
89601
- needsOnlineVerification: customFieldTaskCount > 0 || conditionFieldRefs.length > 0 || hasSavedQuery
90060
+ hasEstimationMapping,
90061
+ needsOnlineVerification: customFieldTaskCount > 0 || conditionFieldRefs.length > 0 || hasSavedQuery || hasEstimationMapping
89602
90062
  };
89603
90063
  }
89604
90064
  async function verifyTemplateProject(template, options) {
@@ -89615,12 +90075,28 @@ async function verifyTemplateProject(template, options) {
89615
90075
  return result;
89616
90076
  }
89617
90077
  if ((requirements.customFieldTaskCount > 0 || requirements.conditionFieldRefs.length > 0) && options.platform?.getFieldSchemas) {
89618
- const customFields = await verifyTemplateCustomFields(template, options.platform.getFieldSchemas.bind(options.platform));
90078
+ const customFields = await verifyTemplateCustomFields(template, options.platform.getFieldSchemas.bind(options.platform), template.taskType ?? options.platform.getEstimationDefaults?.().taskWorkItemType);
89619
90079
  result.errors.push(...customFields.errors);
89620
90080
  result.warnings.push(...customFields.warnings);
89621
90081
  if (customFields.errors.length > 0)
89622
90082
  result.valid = false;
89623
90083
  }
90084
+ const platform = options.platform;
90085
+ if (requirements.hasEstimationMapping && platform?.getFieldSchemas) {
90086
+ const estimation = await verifyEstimationMapping(template, {
90087
+ getFieldSchemas: platform.getFieldSchemas.bind(platform),
90088
+ getWorkItemTypes: platform.getWorkItemTypes?.bind(platform),
90089
+ getEstimationDefaults: platform.getEstimationDefaults?.bind(platform)
90090
+ });
90091
+ result.errors.push(...estimation.errors);
90092
+ if (options.strict === true || template.validation?.mode === "strict") {
90093
+ result.errors.push(...estimation.warnings.map((w2) => ({ path: w2.path, message: w2.message, suggestion: w2.suggestion, code: "STRICT_MODE_WARNING" })));
90094
+ } else {
90095
+ result.warnings.push(...estimation.warnings);
90096
+ }
90097
+ if (result.errors.length > 0)
90098
+ result.valid = false;
90099
+ }
89624
90100
  if (requirements.hasSavedQuery && options.platform?.listSavedQueries) {
89625
90101
  const savedQuery = await verifySavedQuery(template, options.platform);
89626
90102
  result.errors.push(...savedQuery.errors);
@@ -89676,6 +90152,7 @@ async function verifySavedQuery(template, platform) {
89676
90152
  var init_project_verifier = __esm(() => {
89677
90153
  init_condition_evaluator();
89678
90154
  init_custom_field_verifier();
90155
+ init_estimation_verifier();
89679
90156
  });
89680
90157
 
89681
90158
  // ../atomize-core/src/templates/template-verification.ts
@@ -92800,6 +93277,25 @@ function assertNotCancelled(value) {
92800
93277
  }
92801
93278
  return value;
92802
93279
  }
93280
+ function parseConversionTable(input2) {
93281
+ const entries = input2.split(",").map((entry) => entry.trim()).filter(Boolean);
93282
+ if (entries.length === 0)
93283
+ return "Enter at least one entry, e.g. S=2, M=4, L=5";
93284
+ const table = {};
93285
+ for (const entry of entries) {
93286
+ const separator = entry.lastIndexOf("=");
93287
+ const key = separator > 0 ? entry.slice(0, separator).trim() : "";
93288
+ const value = Number(entry.slice(separator + 1).trim());
93289
+ if (!key || separator === entry.length - 1 || Number.isNaN(value))
93290
+ return `"${entry}" is not in the form value=amount`;
93291
+ if (value < 0)
93292
+ return `"${key}" cannot have a negative amount`;
93293
+ if (Object.hasOwn(table, key))
93294
+ return `"${key}" appears more than once`;
93295
+ table[key] = value;
93296
+ }
93297
+ return table;
93298
+ }
92803
93299
  async function selectOrAutocomplete(opts) {
92804
93300
  const count = opts.thresholdCount ?? opts.options.length;
92805
93301
  if (count > AUTOCOMPLETE_THRESHOLD) {
@@ -92888,6 +93384,25 @@ var init_prompt_utilities = __esm(() => {
92888
93384
  return required2;
92889
93385
  return Validators.maxLength(fieldName, maxLength)(input2);
92890
93386
  },
93387
+ positiveNumber: (fieldName) => (input2) => {
93388
+ const num = Number(input2);
93389
+ if (!input2 || input2.trim() === "" || Number.isNaN(num))
93390
+ return `${fieldName} must be a number`;
93391
+ if (num <= 0)
93392
+ return `${fieldName} must be greater than 0`;
93393
+ return;
93394
+ },
93395
+ fieldReferenceName: (input2) => {
93396
+ if (!input2 || input2.trim() === "")
93397
+ return "Field reference name is required";
93398
+ if (/\s/.test(input2.trim()))
93399
+ return "Use the field's reference name (e.g. Custom.TShirtSize), which has no spaces";
93400
+ return;
93401
+ },
93402
+ conversionTable: (input2) => {
93403
+ const parsed = parseConversionTable(input2 ?? "");
93404
+ return typeof parsed === "string" ? parsed : undefined;
93405
+ },
92891
93406
  estimationPercent: (input2) => {
92892
93407
  const num = Number(input2);
92893
93408
  if (Number.isNaN(num))
@@ -93201,9 +93716,19 @@ var init_profile_resolver = __esm(() => {
93201
93716
  init_keychain_service();
93202
93717
  });
93203
93718
 
93719
+ // ../atomize-core/src/services/template/estimation-grounding.ts
93720
+ function isEstimateCapableField(field, rules) {
93721
+ return isEstimateSourceField(field, rules);
93722
+ }
93723
+ var init_estimation_grounding = __esm(() => {
93724
+ init_src();
93725
+ init_src();
93726
+ });
93727
+
93204
93728
  // src/cli/commands/template/template-wizard-helper.command.ts
93205
93729
  var exports_template_wizard_helper_command = {};
93206
93730
  __export(exports_template_wizard_helper_command, {
93731
+ renderConversionTable: () => renderConversionTable,
93207
93732
  promptMixinRefs: () => promptMixinRefs,
93208
93733
  configureValidation: () => configureValidation,
93209
93734
  configureTemplateComposition: () => configureTemplateComposition,
@@ -93268,7 +93793,7 @@ async function configureFilter(ctx, defaults) {
93268
93793
  initialValue: defaultFilterMode
93269
93794
  }));
93270
93795
  if (filterMode === "savedQuery") {
93271
- return promptSavedQuery(ctx.savedQueries);
93796
+ return promptSavedQuery(ctx, defaults);
93272
93797
  }
93273
93798
  const filter = {};
93274
93799
  const workItemTypes = await promptWorkItemTypes(ctx, defaults?.workItemTypes);
@@ -93353,8 +93878,8 @@ async function configureFilter(ctx, defaults) {
93353
93878
  }
93354
93879
  return filter;
93355
93880
  }
93356
- async function promptSavedQuery(queries) {
93357
- const sorted = [...queries].sort((a3, b3) => {
93881
+ async function promptSavedQuery(ctx, defaults) {
93882
+ const sorted = [...ctx.savedQueries].sort((a3, b3) => {
93358
93883
  if (a3.isPublic !== b3.isPublic)
93359
93884
  return a3.isPublic ? -1 : 1;
93360
93885
  return a3.path.localeCompare(b3.path);
@@ -93368,16 +93893,21 @@ async function promptSavedQuery(queries) {
93368
93893
  })),
93369
93894
  placeholder: "Type to filter by path or name..."
93370
93895
  });
93896
+ const workItemTypes = await promptWorkItemTypes(ctx, defaults?.workItemTypes, {
93897
+ message: "Which work item types does this query return? (used to look up their fields, not to filter)",
93898
+ required: true
93899
+ });
93371
93900
  const excludeIfHasTasks = assertNotCancelled(await confirm({
93372
93901
  message: "Exclude work items that already have tasks?",
93373
93902
  initialValue: true
93374
93903
  }));
93375
93904
  return {
93376
93905
  savedQuery: { id: selectedId.trim() },
93906
+ workItemTypes,
93377
93907
  ...excludeIfHasTasks && { excludeIfHasTasks: true }
93378
93908
  };
93379
93909
  }
93380
- async function promptWorkItemTypes(ctx, defaults) {
93910
+ async function promptWorkItemTypes(ctx, defaults, options = {}) {
93381
93911
  const defaultType = ctx.workItemTypes.includes("User Story") ? "User Story" : undefined;
93382
93912
  const sorted = [
93383
93913
  ...defaultType ? [defaultType] : [],
@@ -93385,10 +93915,10 @@ async function promptWorkItemTypes(ctx, defaults) {
93385
93915
  ];
93386
93916
  const initialValues = defaults && defaults.length > 0 ? defaults.filter((t3) => ctx.workItemTypes.includes(t3)) : defaultType ? [defaultType] : [];
93387
93917
  const selected = assertNotCancelled(await multiselect({
93388
- message: "Select work item types:",
93918
+ message: options.message ?? "Select work item types:",
93389
93919
  options: sorted.map((t3) => ({ label: t3, value: t3 })),
93390
93920
  initialValues,
93391
- required: false
93921
+ required: options.required ?? false
93392
93922
  }));
93393
93923
  return selected;
93394
93924
  }
@@ -93681,7 +94211,12 @@ async function promptDateFilter(selectMessage, customMessage, defaults) {
93681
94211
  }
93682
94212
  return choice;
93683
94213
  }
93684
- async function configureEstimation(defaults) {
94214
+ async function configureEstimation(defaults, ctx = {}, currentTaskType) {
94215
+ const source = await promptEstimationSource(defaults?.source, ctx);
94216
+ const sourceField = source ? ctx.storyFields?.find((field) => field.referenceName === source) : undefined;
94217
+ const conversion = await promptConversion(defaults?.conversion, sourceField);
94218
+ const sourceValues = sourceField?.isPicklist ? sourceField.allowedValues ?? [] : [];
94219
+ const { taskType, targetFields } = await promptEstimateTarget(currentTaskType, defaults?.targetFields, ctx);
93685
94220
  const rounding = assertNotCancelled(await select({
93686
94221
  message: "Rounding strategy:",
93687
94222
  options: [
@@ -93692,23 +94227,267 @@ async function configureEstimation(defaults) {
93692
94227
  ],
93693
94228
  initialValue: defaults?.rounding ?? "none"
93694
94229
  }));
93695
- const minimumTaskPointsRaw = assertNotCancelled(await text({
93696
- message: "Minimum task points (0 for no minimum):",
93697
- initialValue: String(defaults?.minimumTaskPoints ?? 0),
93698
- validate: (input2) => {
93699
- const n4 = Number(input2);
93700
- if (Number.isNaN(n4))
93701
- return "Must be a valid number";
93702
- if (n4 < 0)
93703
- return "Cannot be negative";
93704
- return;
93705
- }
94230
+ const minimumTaskEstimateRaw = assertNotCancelled(await text({
94231
+ message: "Minimum Task Estimate, in the Task's unit (0 for no minimum):",
94232
+ initialValue: String(defaults?.minimumTaskEstimate ?? defaults?.minimumTaskPoints ?? 0),
94233
+ validate: Validators.nonNegative("Minimum Task Estimate")
93706
94234
  }));
93707
- return {
94235
+ const normalize = assertNotCancelled(await select({
94236
+ message: "When the generated tasks' percentages don't add up to 100%:",
94237
+ options: [
94238
+ { label: "Fill up to 100% (scale up below 100%, keep anything above)", value: "auto" },
94239
+ { label: "Always scale to exactly 100%", value: "always" },
94240
+ { label: "Use the percentages as written", value: "never" }
94241
+ ],
94242
+ initialValue: defaults?.normalize ?? "auto"
94243
+ }));
94244
+ const policy = assertNotCancelled(await select({
94245
+ message: "When a Story's estimate is missing or can't be converted:",
94246
+ options: [
94247
+ { label: "Create the tasks with blank estimates and warn", value: "warn" },
94248
+ { label: "Skip the Story", value: "skip" },
94249
+ { label: "Assume a default Story Estimate", value: "use-default" }
94250
+ ],
94251
+ initialValue: defaults?.ifParentHasNoEstimation ?? "warn"
94252
+ }));
94253
+ const defaultParentEstimation = policy === "use-default" ? await promptDefaultStoryEstimate(defaults?.defaultParentEstimation, conversion, sourceValues) : undefined;
94254
+ const {
94255
+ source: _source,
94256
+ conversion: _conversion,
94257
+ targetFields: _targetFields,
94258
+ minimumTaskPoints: _minimumTaskPoints,
94259
+ defaultParentEstimation: _default3,
94260
+ ifParentHasNoEstimation: _policy,
94261
+ normalize: _normalize2,
94262
+ ...kept
94263
+ } = defaults ?? {};
94264
+ const estimation = {
94265
+ ...kept,
93708
94266
  strategy: "percentage",
93709
94267
  rounding,
93710
- minimumTaskPoints: Number(minimumTaskPointsRaw) || undefined
93711
- };
94268
+ minimumTaskEstimate: Number(minimumTaskEstimateRaw) || undefined,
94269
+ ...source ? { source } : {},
94270
+ ...conversion ? { conversion } : {},
94271
+ ...targetFields?.length ? { targetFields } : {},
94272
+ ...normalize !== "auto" ? { normalize } : {},
94273
+ ...policy !== "warn" ? { ifParentHasNoEstimation: policy } : {},
94274
+ ...defaultParentEstimation !== undefined ? { defaultParentEstimation } : {}
94275
+ };
94276
+ if (estimation.minimumTaskEstimate === undefined)
94277
+ delete estimation.minimumTaskEstimate;
94278
+ return { estimation, ...taskType ? { taskType } : {} };
94279
+ }
94280
+ async function promptEstimationSource(current, ctx) {
94281
+ const rules = { storyEstimateFields: ctx.defaults?.storyEstimateFields ?? [], nonEstimateFields: ctx.defaults?.nonEstimateFields };
94282
+ const candidates = sortEstimateFields((ctx.storyFields ?? []).filter((field) => isEstimateCapableField(field, rules)));
94283
+ if (ctx.storyFields !== undefined) {
94284
+ const choice = assertNotCancelled(await select({
94285
+ message: "Which Story field holds the Story Estimate?",
94286
+ options: [
94287
+ { label: "Platform default (story points, effort or size)", value: "" },
94288
+ ...candidates.map((field) => ({
94289
+ label: `${field.name} (${field.referenceName})`,
94290
+ value: field.referenceName,
94291
+ hint: field.isPicklist ? `values: ${(field.allowedValues ?? []).join(", ")}` : field.type
94292
+ })),
94293
+ { label: "Another field (enter its reference name)", value: CUSTOM_ENTRY }
94294
+ ],
94295
+ initialValue: current && candidates.some((field) => field.referenceName === current) ? current : current ? CUSTOM_ENTRY : ""
94296
+ }));
94297
+ if (choice !== CUSTOM_ENTRY)
94298
+ return choice || undefined;
94299
+ }
94300
+ const typed = assertNotCancelled(await text({
94301
+ message: "Story field reference name holding the Story Estimate (leave blank for the platform default):",
94302
+ initialValue: current ?? "",
94303
+ validate: (input2) => input2 && input2.trim() !== "" ? Validators.fieldReferenceName(input2) : undefined
94304
+ }));
94305
+ return typed.trim() || undefined;
94306
+ }
94307
+ async function promptConversion(current, sourceField) {
94308
+ const kind = assertNotCancelled(await select({
94309
+ message: "How does the Story Estimate translate into Task hours?",
94310
+ options: [
94311
+ { label: "One-to-one (use the number as it is)", value: "none" },
94312
+ { label: "A factor (e.g. 1 point = 4 hours)", value: "factor" },
94313
+ { label: "A table (e.g. t-shirt sizes, or points that don't scale linearly)", value: "table" }
94314
+ ],
94315
+ initialValue: current?.table ? "table" : current?.factor !== undefined ? "factor" : sourceField?.isPicklist ? "table" : "none"
94316
+ }));
94317
+ if (kind === "none")
94318
+ return;
94319
+ if (kind === "factor") {
94320
+ const factor = assertNotCancelled(await text({
94321
+ message: "Task hours per unit of Story Estimate:",
94322
+ initialValue: current?.factor !== undefined ? String(current.factor) : "",
94323
+ validate: Validators.positiveNumber("Factor")
94324
+ }));
94325
+ return { factor: Number(factor) };
94326
+ }
94327
+ const table = await promptConversionTable(current?.table, sourceField);
94328
+ const keysAreCategories = !Object.keys(table).every((key) => /^(?:\d+(?:\.\d+)?|\.\d+)$/.test(key));
94329
+ const multipliers = keysAreCategories && assertNotCancelled(await confirm({
94330
+ message: "Also accept multiples of a size, e.g. 0.3XL = 0.3 × XL?",
94331
+ initialValue: current?.multipliers === true
94332
+ }));
94333
+ return { table, ...multipliers ? { multipliers: true } : {} };
94334
+ }
94335
+ function renderConversionTable(table, source) {
94336
+ const allowed = source?.allowedValues ?? [];
94337
+ const entries = Object.entries(table);
94338
+ const heading = `Conversion table — ${source?.name ?? "Story Estimate"} → hours`;
94339
+ const lines = [allowed.length ? `${heading} ${allowed.filter((v2) => Object.hasOwn(table, v2)).length} of ${allowed.length} values covered` : heading];
94340
+ if (entries.length === 0) {
94341
+ lines.push(" (no values yet)");
94342
+ } else {
94343
+ const width = Math.max(5, ...entries.map(([key]) => key.length));
94344
+ lines.push(` ${"Value".padEnd(width)} ${"Hours".padEnd(6)} A 20% task gets`);
94345
+ for (const [key, hours] of entries) {
94346
+ const flag = allowed.length && !allowed.includes(key) ? " not in picklist" : "";
94347
+ lines.push(` ${key.padEnd(width)} ${String(hours).padEnd(6)} ${Math.round(hours * 20) / 100} h${flag}`);
94348
+ }
94349
+ }
94350
+ const missing = allowed.filter((value) => !Object.hasOwn(table, value));
94351
+ if (missing.length)
94352
+ lines.push(` Missing: ${missing.join(", ")} — Stories with ${missing.length === 1 ? "this value" : "these values"} get blank estimates.`);
94353
+ return lines;
94354
+ }
94355
+ async function promptConversionTable(current, sourceField) {
94356
+ const allowed = sourceField?.isPicklist ? sourceField.allowedValues ?? [] : [];
94357
+ const table = { ...current ?? {} };
94358
+ if (allowed.length && !current) {
94359
+ output3.print(source_default.gray(`Enter the Task hours for each ${sourceField?.name ?? "value"}. Leave blank to skip a value.`));
94360
+ for (const [index, value] of allowed.entries()) {
94361
+ const hours = assertNotCancelled(await text({
94362
+ message: `Hours for "${value}" (${index + 1} of ${allowed.length}):`,
94363
+ validate: (input2) => input2 && input2.trim() !== "" ? Validators.nonNegative("Hours")(input2) : undefined
94364
+ }));
94365
+ if (hours.trim() !== "")
94366
+ table[value] = Number(hours);
94367
+ }
94368
+ }
94369
+ for (;; ) {
94370
+ output3.blankLine();
94371
+ for (const line of renderConversionTable(table, sourceField))
94372
+ output3.print(source_default.gray(line));
94373
+ const missing = allowed.filter((value) => !Object.hasOwn(table, value));
94374
+ const keys = Object.keys(table);
94375
+ const action = assertNotCancelled(await select({
94376
+ message: "Conversion table",
94377
+ options: [
94378
+ ...missing.map((value) => ({ label: `Add hours for ${value}`, value: `add:${value}` })),
94379
+ { label: allowed.length ? "Add another value…" : "Add a value", value: "add" },
94380
+ ...keys.length ? [{ label: "Change a value's hours", value: "change" }, { label: "Remove a value", value: "remove" }] : [],
94381
+ { label: "Paste several (e.g. S=2, M=4)", value: "paste" },
94382
+ { label: "Done", value: "done" }
94383
+ ],
94384
+ initialValue: missing.length ? `add:${missing[0]}` : keys.length ? "done" : "add"
94385
+ }));
94386
+ if (action === "done") {
94387
+ if (keys.length)
94388
+ return table;
94389
+ output3.print(source_default.yellow("Add at least one value before finishing."));
94390
+ continue;
94391
+ }
94392
+ if (action.startsWith("add:")) {
94393
+ const value = action.slice(4);
94394
+ table[value] = Number(assertNotCancelled(await text({ message: `Hours for "${value}":`, validate: hoursValidator })));
94395
+ continue;
94396
+ }
94397
+ if (action === "add") {
94398
+ const value = assertNotCancelled(await text({
94399
+ message: "Story Estimate value (e.g. XL, or 8 for points):",
94400
+ validate: (input2) => {
94401
+ if (!input2 || input2.trim() === "")
94402
+ return "Enter a value";
94403
+ if (Object.hasOwn(table, input2.trim()))
94404
+ return `"${input2.trim()}" is already in the table`;
94405
+ return;
94406
+ }
94407
+ })).trim();
94408
+ table[value] = Number(assertNotCancelled(await text({ message: `Hours for "${value}":`, validate: hoursValidator })));
94409
+ continue;
94410
+ }
94411
+ if (action === "change") {
94412
+ const value = assertNotCancelled(await select({ message: "Which value?", options: keys.map((key) => ({ label: `${key} (${table[key]} h)`, value: key })) }));
94413
+ table[value] = Number(assertNotCancelled(await text({ message: `Hours for "${value}":`, initialValue: String(table[value]), validate: hoursValidator })));
94414
+ continue;
94415
+ }
94416
+ if (action === "remove") {
94417
+ const value = assertNotCancelled(await select({ message: "Remove which value?", options: keys.map((key) => ({ label: `${key} (${table[key]} h)`, value: key })) }));
94418
+ delete table[value];
94419
+ continue;
94420
+ }
94421
+ const pasted = assertNotCancelled(await text({ message: "Value=hours pairs, comma-separated (e.g. S=2, M=4, L=5):", validate: Validators.conversionTable }));
94422
+ Object.assign(table, parseConversionTable(pasted));
94423
+ }
94424
+ }
94425
+ async function promptEstimateTarget(currentTaskType, currentTargetFields, ctx) {
94426
+ const customise = assertNotCancelled(await confirm({
94427
+ message: `Create tasks as a different work item type, or write estimates to different fields? (default: ${ctx.defaults?.taskWorkItemType ?? "platform default"})`,
94428
+ initialValue: currentTaskType !== undefined || (currentTargetFields?.length ?? 0) > 0
94429
+ }));
94430
+ if (!customise)
94431
+ return {};
94432
+ const defaultType = ctx.defaults?.taskWorkItemType;
94433
+ let taskType;
94434
+ if (ctx.workItemTypes?.length) {
94435
+ taskType = assertNotCancelled(await select({
94436
+ message: "Work item type to create tasks as:",
94437
+ options: ctx.workItemTypes.map((type) => ({ label: type, value: type, hint: type === defaultType ? "platform default" : undefined })),
94438
+ initialValue: currentTaskType ?? defaultType ?? ctx.workItemTypes[0]
94439
+ }));
94440
+ } else {
94441
+ taskType = assertNotCancelled(await text({
94442
+ message: "Work item type to create tasks as:",
94443
+ initialValue: currentTaskType ?? defaultType ?? "",
94444
+ validate: Validators.required("Work item type")
94445
+ })).trim();
94446
+ }
94447
+ const numericFields = ctx.getTaskFields ? sortEstimateFields((await ctx.getTaskFields(taskType)).filter((field) => isEstimateTargetField(field, ctx.defaults))) : [];
94448
+ let targetFields;
94449
+ if (numericFields.length > 0) {
94450
+ targetFields = assertNotCancelled(await multiselect({
94451
+ message: "Task fields that receive the estimate (none selected = platform default fields):",
94452
+ options: numericFields.map((field) => ({ label: `${field.name} (${field.referenceName})`, value: field.referenceName })),
94453
+ initialValues: currentTargetFields ?? [],
94454
+ required: false
94455
+ }));
94456
+ } else {
94457
+ const typed = assertNotCancelled(await text({
94458
+ message: "Task field reference names that receive the estimate, comma-separated (blank = platform default fields):",
94459
+ initialValue: currentTargetFields?.join(", ") ?? ""
94460
+ }));
94461
+ targetFields = Filters.commaSeparated(typed).filter(Boolean);
94462
+ }
94463
+ const keepTaskType = taskType !== defaultType || currentTaskType !== undefined;
94464
+ return { ...keepTaskType ? { taskType } : {}, ...targetFields.length ? { targetFields } : {} };
94465
+ }
94466
+ async function promptDefaultStoryEstimate(current, conversion, sourceValues) {
94467
+ const choices = (sourceValues.length ? sourceValues : Object.keys(conversion?.table ?? {})).filter((value) => isConvertibleStoryEstimate(value, conversion));
94468
+ if (choices.length > 0) {
94469
+ const currentValue = current === undefined ? undefined : String(current);
94470
+ const choice = assertNotCancelled(await select({
94471
+ message: "Default Story Estimate to assume:",
94472
+ options: [
94473
+ ...choices.map((value) => ({ label: value, value })),
94474
+ ...conversion?.multipliers ? [{ label: "Another value (e.g. 0.5L)", value: CUSTOM_ENTRY }] : []
94475
+ ],
94476
+ initialValue: currentValue && choices.includes(currentValue) ? currentValue : currentValue && conversion?.multipliers ? CUSTOM_ENTRY : choices[0]
94477
+ }));
94478
+ if (choice !== CUSTOM_ENTRY)
94479
+ return choice;
94480
+ }
94481
+ const typed = assertNotCancelled(await text({
94482
+ message: "Default Story Estimate to assume (e.g. 5, or a size like M):",
94483
+ initialValue: current !== undefined ? String(current) : "",
94484
+ validate: (input2) => {
94485
+ if (!input2 || input2.trim() === "")
94486
+ return "A default Story Estimate is required";
94487
+ return isConvertibleStoryEstimate(input2.trim(), conversion) ? undefined : conversion?.table ? "Must be a value of the conversion table, or a multiple of one" : "Must be a number unless a conversion table is set";
94488
+ }
94489
+ })).trim();
94490
+ return Number.isFinite(Number(typed)) ? Number(typed) : typed;
93712
94491
  }
93713
94492
  async function configureValidation(defaults) {
93714
94493
  const validation = {};
@@ -93953,9 +94732,15 @@ async function promptMixinRefs(mixins) {
93953
94732
  function formatCatalogChoice(item) {
93954
94733
  return `${item.displayName} (${item.scope})`;
93955
94734
  }
93956
- var output3, ADVANCED_GROUP_ORDER, DATE_FILTER_OPTIONS;
94735
+ var output3, ADVANCED_GROUP_ORDER, DATE_FILTER_OPTIONS, CUSTOM_ENTRY = "__custom__", hoursValidator = (input2) => {
94736
+ if (!input2 || input2.trim() === "")
94737
+ return "Enter the hours";
94738
+ return Validators.nonNegative("Hours")(input2);
94739
+ };
93957
94740
  var init_template_wizard_helper_command = __esm(() => {
93958
94741
  init_dist6();
94742
+ init_estimation_grounding();
94743
+ init_schema2();
93959
94744
  init_source();
93960
94745
  init_command_output();
93961
94746
  init_prompt_utilities();
@@ -97894,7 +98679,7 @@ var program = new Command;
97894
98679
 
97895
98680
  // package.json
97896
98681
  var name = "@sppg2001/atomize";
97897
- var version2 = "4.0.1";
98682
+ var version2 = "4.1.0";
97898
98683
 
97899
98684
  // src/cli/commands/auth/auth-add.command.ts
97900
98685
  init_dist6();
@@ -99396,16 +100181,26 @@ function printReport(report, options, dryRun, storyIds) {
99396
100181
  `));
99397
100182
  for (const result of report.results) {
99398
100183
  if (result.success) {
100184
+ if (result.skipReason) {
100185
+ output2.print(source_default.yellow(`⊘ ${sanitizeTty(result.story.id)}: ${sanitizeTty(result.story.title)}`));
100186
+ output2.print(source_default.gray(` Skipped: ${sanitizeTty(result.skipReason)}`));
100187
+ continue;
100188
+ }
99399
100189
  output2.print(source_default.green(`✓ ${sanitizeTty(result.story.id)}: ${sanitizeTty(result.story.title)}`));
99400
- output2.print(source_default.gray(` Estimation: ${result.story.estimation || 0} points`));
100190
+ const withUnit = (value) => result.estimateUnit ? `${value} ${result.estimateUnit}` : String(value);
100191
+ const rawEstimate = result.storyEstimate;
100192
+ const converted = result.estimationSummary?.storyEstimation;
100193
+ const conversionNote = converted !== undefined && converted !== rawEstimate ? ` (converted to ${withUnit(converted)})` : "";
100194
+ output2.print(source_default.gray(` Estimation: ${rawEstimate === undefined ? "none" : sanitizeTty(String(rawEstimate))}${conversionNote}`));
99401
100195
  output2.print(source_default.gray(` Tasks: ${result.tasksCalculated.length}`));
99402
100196
  if (result.estimationSummary) {
99403
- output2.print(source_default.gray(` Distribution: ${result.estimationSummary.totalTaskEstimation} points (${result.estimationSummary.percentageUsed.toFixed(0)}%)`));
100197
+ output2.print(source_default.gray(` Distribution: ${withUnit(result.estimationSummary.totalTaskEstimation)} (${result.estimationSummary.percentageUsed.toFixed(0)}%)`));
99404
100198
  }
99405
100199
  if ((options.verbose || dryRun) && result.tasksCalculated.length > 0) {
99406
100200
  output2.print(source_default.gray(" Task breakdown:"));
99407
100201
  for (const task of result.tasksCalculated) {
99408
- output2.print(source_default.gray(` - ${sanitizeTty(task.title)}: ${task.estimation} points (${task.estimationPercent}%)`));
100202
+ const estimate = task.estimation === undefined ? "unestimated" : withUnit(task.estimation);
100203
+ output2.print(source_default.gray(` - ${sanitizeTty(task.title)}: ${estimate} (${task.estimationPercent}%)`));
99409
100204
  }
99410
100205
  }
99411
100206
  } else {
@@ -99577,6 +100372,8 @@ function makeGenerateCommand(makeOutput, prompts, config2) {
99577
100372
  });
99578
100373
  }
99579
100374
  async function resolveNormalization(template, isTTYSession, prompts) {
100375
+ if (template.estimation?.normalize && template.estimation.normalize !== "auto")
100376
+ return false;
99580
100377
  const { shouldOffer, total } = shouldOfferOverageNormalization(template.tasks);
99581
100378
  if (!shouldOffer)
99582
100379
  return false;
@@ -99594,6 +100391,7 @@ async function resolveNormalization(template, isTTYSession, prompts) {
99594
100391
  init_condition_evaluator();
99595
100392
  init_dependency_resolver();
99596
100393
  init_estimation_calculator();
100394
+ init_mock_adapter();
99597
100395
  var STANDARD_FIELD_TYPES = {
99598
100396
  id: "string",
99599
100397
  title: "string",
@@ -99652,7 +100450,11 @@ function inspectTemplate(template) {
99652
100450
  }
99653
100451
  }
99654
100452
  if (template.tasks.some((task) => task.estimationPercent !== undefined || task.estimationPercentCondition?.length)) {
99655
- upsert("estimation", "number", "estimation", { required: true });
100453
+ const source = template.estimation?.source;
100454
+ if (source)
100455
+ upsert(source, "unknown", "estimation", { required: true });
100456
+ else
100457
+ upsert("estimation", "number", "estimation", { required: true });
99656
100458
  }
99657
100459
  for (const name2 of filterFields) {
99658
100460
  upsert(name2, STANDARD_FIELD_TYPES[name2] ?? "unknown", "filter");
@@ -99695,13 +100497,16 @@ function runPreview(template, mockStoryJson) {
99695
100497
  const story = parseMockStory(mockStoryJson);
99696
100498
  const orderedTasks = new DependencyResolver().resolveDependencies(template.tasks);
99697
100499
  const calc = new EstimationCalculator;
99698
- const { calculatedTasks, skippedTasks } = calc.calculateTasksWithSkipped(story, "", orderedTasks, template.estimation);
99699
- const summary = calc.getEstimationSummary(story, calculatedTasks);
100500
+ const fieldMapping = resolveEstimationFieldMapping(MOCK_ESTIMATION_DEFAULTS, {
100501
+ source: template.estimation?.source,
100502
+ targetFields: template.estimation?.targetFields
100503
+ });
100504
+ const { calculatedTasks, skippedTasks, unresolvedEstimate } = calc.calculateTasksWithSkipped(story, "", orderedTasks, template.estimation, false, fieldMapping);
100505
+ const summary = calc.getEstimationSummary(story, calculatedTasks, template.estimation, fieldMapping);
99700
100506
  const tasks = calculatedTasks.map((task) => {
99701
- const result = {
99702
- title: task.title,
99703
- estimation: task.estimation ?? 0
99704
- };
100507
+ const result = { title: task.title };
100508
+ if (task.estimation !== undefined)
100509
+ result.estimation = task.estimation;
99705
100510
  if (task.estimationPercent !== undefined)
99706
100511
  result.estimationPercent = task.estimationPercent;
99707
100512
  if (task.dependsOn?.length)
@@ -99720,6 +100525,8 @@ function runPreview(template, mockStoryJson) {
99720
100525
  title: s2.templateTask.title,
99721
100526
  reason: s2.reason
99722
100527
  })),
100528
+ ...unresolvedEstimate ? { unresolvedEstimate } : {},
100529
+ ...fieldMapping.unitLabel ? { estimateUnit: fieldMapping.unitLabel } : {},
99723
100530
  estimationSummary: {
99724
100531
  storyEstimation: summary.storyEstimation,
99725
100532
  totalTaskEstimation: summary.totalTaskEstimation,
@@ -100041,6 +100848,70 @@ init_errors();
100041
100848
  // ../atomize-core/src/services/template/learned-template-product.ts
100042
100849
  init_estimation_distribution();
100043
100850
 
100851
+ // ../atomize-core/src/core/estimation-inference.ts
100852
+ function taskEstimateShares(taskEstimates) {
100853
+ const total = taskEstimates.reduce((sum, value) => sum + (value ?? 0), 0);
100854
+ if (total <= 0)
100855
+ return taskEstimates.map(() => 0);
100856
+ return taskEstimates.map((value) => Math.round((value ?? 0) / total * 100));
100857
+ }
100858
+ function inferEstimation(samples) {
100859
+ const usable = samples.map((sample) => ({ estimate: sample.storyEstimate, total: sum(sample.taskEstimates) })).filter((s2) => s2.estimate !== undefined && s2.total > 0);
100860
+ if (usable.length === 0)
100861
+ return { lowConfidenceValues: [] };
100862
+ const defaultStoryEstimate = mostCommon(usable.map((s2) => s2.estimate));
100863
+ const allNumeric = usable.every((s2) => numericStoryEstimate(s2.estimate) !== undefined);
100864
+ if (allNumeric) {
100865
+ const ratios = usable.map((s2) => ({ points: numericStoryEstimate(s2.estimate) ?? 0, total: s2.total })).filter((s2) => s2.points > 0).map((s2) => s2.total / s2.points);
100866
+ if (ratios.length === 0)
100867
+ return { defaultStoryEstimate, lowConfidenceValues: [] };
100868
+ const factor = roundTo(median(ratios), 2);
100869
+ return {
100870
+ ...factor !== 1 ? { conversion: { factor } } : {},
100871
+ defaultStoryEstimate,
100872
+ lowConfidenceValues: []
100873
+ };
100874
+ }
100875
+ const totalsByValue = new Map;
100876
+ for (const s2 of usable) {
100877
+ const key = String(s2.estimate).trim();
100878
+ totalsByValue.set(key, [...totalsByValue.get(key) ?? [], s2.total]);
100879
+ }
100880
+ const table = {};
100881
+ const lowConfidenceValues = [];
100882
+ for (const [key, totals] of totalsByValue) {
100883
+ table[key] = roundTo(median(totals), 2);
100884
+ if (totals.length === 1)
100885
+ lowConfidenceValues.push(key);
100886
+ }
100887
+ return { conversion: { table }, defaultStoryEstimate, lowConfidenceValues };
100888
+ }
100889
+ function sum(values) {
100890
+ return values.reduce((total, value) => total + (value ?? 0), 0);
100891
+ }
100892
+ function median(values) {
100893
+ const sorted = [...values].sort((a3, b3) => a3 - b3);
100894
+ const mid = Math.floor(sorted.length / 2);
100895
+ return sorted.length % 2 === 0 ? ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2 : sorted[mid] ?? 0;
100896
+ }
100897
+ function mostCommon(values) {
100898
+ const counts = new Map;
100899
+ for (const value of values)
100900
+ counts.set(value, (counts.get(value) ?? 0) + 1);
100901
+ let best;
100902
+ let bestCount = 0;
100903
+ for (const [value, count] of counts) {
100904
+ if (count > bestCount) {
100905
+ best = value;
100906
+ bestCount = count;
100907
+ }
100908
+ }
100909
+ return best;
100910
+ }
100911
+ function roundTo(value, decimals) {
100912
+ const scale = 10 ** decimals;
100913
+ return Math.round(value * scale) / scale;
100914
+ }
100044
100915
  // ../atomize-core/src/services/template/pattern-detection.ts
100045
100916
  init_dist();
100046
100917
  var DEFAULT_PATTERN_SCORING_CONFIG = {
@@ -100631,12 +101502,12 @@ class ConditionPatternDetector {
100631
101502
  }
100632
101503
  detectEstimationConditions(analyses, commonTasks, threshold) {
100633
101504
  const patterns = [];
100634
- const estimations = analyses.map((a3) => a3.story.estimation).filter((e2) => e2 !== undefined && e2 > 0).sort((a3, b3) => a3 - b3);
101505
+ const estimations = analyses.map((a3) => numericStoryEstimate(a3.story.estimation)).filter((e2) => e2 !== undefined && e2 > 0).sort((a3, b3) => a3 - b3);
100635
101506
  if (estimations.length < this.minSampleSize)
100636
101507
  return patterns;
100637
- const median = estimations[Math.floor(estimations.length / 2)] ?? 0;
100638
- const q75 = estimations[Math.floor(estimations.length * 3 / 4)] ?? median;
100639
- const thresholds = [...new Set([median, q75])].filter((t3) => t3 > 0);
101508
+ const median2 = estimations[Math.floor(estimations.length / 2)] ?? 0;
101509
+ const q75 = estimations[Math.floor(estimations.length * 3 / 4)] ?? median2;
101510
+ const thresholds = [...new Set([median2, q75])].filter((t3) => t3 > 0);
100640
101511
  for (const commonTask of commonTasks) {
100641
101512
  if (commonTask.frequencyRatio >= 0.9)
100642
101513
  continue;
@@ -100666,7 +101537,7 @@ class ConditionPatternDetector {
100666
101537
  let taskInLargeStories = 0;
100667
101538
  let taskInSmallStories = 0;
100668
101539
  for (const analysis of analyses) {
100669
- const estimation = analysis.story.estimation;
101540
+ const estimation = numericStoryEstimate(analysis.story.estimation);
100670
101541
  if (estimation === undefined)
100671
101542
  continue;
100672
101543
  const isLarge = estimation >= estimationThreshold;
@@ -100779,7 +101650,7 @@ class ConditionPatternDetector {
100779
101650
  return false;
100780
101651
  }
100781
101652
  case "estimation": {
100782
- const e2 = story.estimation;
101653
+ const e2 = numericStoryEstimate(story.estimation);
100783
101654
  if (e2 === undefined)
100784
101655
  return false;
100785
101656
  const v2 = condition.value;
@@ -100950,18 +101821,18 @@ class FilterLearner {
100950
101821
  for (const p3 of priorities) {
100951
101822
  priorityCounts.set(p3, (priorityCounts.get(p3) ?? 0) + 1);
100952
101823
  }
100953
- let mostCommon = min;
101824
+ let mostCommon2 = min;
100954
101825
  let maxCount = 0;
100955
101826
  for (const [priority, count] of priorityCounts) {
100956
101827
  if (count > maxCount) {
100957
- mostCommon = priority;
101828
+ mostCommon2 = priority;
100958
101829
  maxCount = count;
100959
101830
  }
100960
101831
  }
100961
- return { min, max, mostCommon };
101832
+ return { min, max, mostCommon: mostCommon2 };
100962
101833
  }
100963
101834
  detectEstimationRange(analyses) {
100964
- const estimations = analyses.map((a3) => a3.story.estimation).filter((e2) => e2 !== undefined && e2 > 0);
101835
+ const estimations = analyses.map((a3) => numericStoryEstimate(a3.story.estimation)).filter((e2) => e2 !== undefined && e2 > 0);
100965
101836
  if (estimations.length === 0)
100966
101837
  return;
100967
101838
  const sorted = [...estimations].sort((a3, b3) => a3 - b3);
@@ -101042,7 +101913,7 @@ class FilterLearner {
101042
101913
  if (max - min <= 3) {
101043
101914
  suggestions.push({
101044
101915
  type: "improve-filter",
101045
- message: `Stories have similar estimations (${min}-${max} points, avg ${average}). This template works well for stories of this size.`,
101916
+ message: `Stories have similar estimations (${min}-${max}, avg ${average}). This template works well for stories of this size.`,
101046
101917
  severity: "info"
101047
101918
  });
101048
101919
  }
@@ -101065,8 +101936,8 @@ class FilterLearner {
101065
101936
  parts.push(`Area paths: ${learnedFilters.areaPaths.values.join(", ")}`);
101066
101937
  }
101067
101938
  if (learnedFilters.priorityRange) {
101068
- const { min, max, mostCommon } = learnedFilters.priorityRange;
101069
- parts.push(`Priority range: ${min}-${max} (most common: ${mostCommon})`);
101939
+ const { min, max, mostCommon: mostCommon2 } = learnedFilters.priorityRange;
101940
+ parts.push(`Priority range: ${min}-${max} (most common: ${mostCommon2})`);
101070
101941
  }
101071
101942
  if (learnedFilters.estimationRange) {
101072
101943
  const { min, max, average } = learnedFilters.estimationRange;
@@ -101215,7 +102086,7 @@ class PatternDetector extends SimilarityCalculator {
101215
102086
  detectEstimationPattern(analyses) {
101216
102087
  const totalEstimations = [];
101217
102088
  for (const analysis of analyses) {
101218
- const storyEstimation = analysis.story.estimation ?? 0;
102089
+ const storyEstimation = numericStoryEstimate(analysis.story.estimation) ?? 0;
101219
102090
  totalEstimations.push(storyEstimation);
101220
102091
  }
101221
102092
  const avgTotal = totalEstimations.length > 0 ? totalEstimations.reduce((a3, b3) => a3 + b3, 0) / totalEstimations.length : 0;
@@ -101297,7 +102168,7 @@ class ConfidenceScorer {
101297
102168
  };
101298
102169
  }
101299
102170
  scoreTaskConsistency(analyses, patterns) {
101300
- const totalTasks = analyses.reduce((sum, a3) => sum + a3.tasks.length, 0);
102171
+ const totalTasks = analyses.reduce((sum2, a3) => sum2 + a3.tasks.length, 0);
101301
102172
  if (totalTasks === 0) {
101302
102173
  return {
101303
102174
  name: "Task Consistency",
@@ -101377,9 +102248,9 @@ class ConfidenceScorer {
101377
102248
  description: "No tasks to merge"
101378
102249
  };
101379
102250
  }
101380
- const totalOriginalTasks = analyses.reduce((sum, a3) => sum + a3.tasks.length, 0);
102251
+ const totalOriginalTasks = analyses.reduce((sum2, a3) => sum2 + a3.tasks.length, 0);
101381
102252
  const mergeRatio = totalOriginalTasks > 0 ? 1 - mergedTasks.length / totalOriginalTasks : 0;
101382
- const avgSimilarity = mergedTasks.reduce((sum, m3) => sum + m3.similarity, 0) / mergedTasks.length;
102253
+ const avgSimilarity = mergedTasks.reduce((sum2, m3) => sum2 + m3.similarity, 0) / mergedTasks.length;
101383
102254
  const compositeScore = mergeRatio * 0.6 + avgSimilarity * 0.4;
101384
102255
  const score = Math.round(compositeScore * 100);
101385
102256
  return {
@@ -101418,7 +102289,7 @@ class ConfidenceScorer {
101418
102289
  description: "No dependency patterns detected"
101419
102290
  };
101420
102291
  }
101421
- const avgConfidence = dependencyPatterns.reduce((sum, d2) => sum + d2.confidence, 0) / dependencyPatterns.length;
102292
+ const avgConfidence = dependencyPatterns.reduce((sum2, d2) => sum2 + d2.confidence, 0) / dependencyPatterns.length;
101422
102293
  const explicitCount = dependencyPatterns.filter((d2) => d2.source === "explicit").length;
101423
102294
  const explicitRatio = explicitCount / dependencyPatterns.length;
101424
102295
  const explicitBonus = explicitRatio * 10;
@@ -101440,8 +102311,8 @@ class ConfidenceScorer {
101440
102311
  description: "No conditional patterns detected"
101441
102312
  };
101442
102313
  }
101443
- const avgConfidence = conditionalPatterns.reduce((sum, c4) => sum + c4.confidence, 0) / conditionalPatterns.length;
101444
- const avgCoverage = conditionalPatterns.reduce((sum, c4) => sum + (c4.totalStories > 0 ? c4.matchCount / c4.totalStories : 0), 0) / conditionalPatterns.length;
102314
+ const avgConfidence = conditionalPatterns.reduce((sum2, c4) => sum2 + c4.confidence, 0) / conditionalPatterns.length;
102315
+ const avgCoverage = conditionalPatterns.reduce((sum2, c4) => sum2 + (c4.totalStories > 0 ? c4.matchCount / c4.totalStories : 0), 0) / conditionalPatterns.length;
101445
102316
  const score = Math.round(Math.min(100, avgConfidence * 70 + avgCoverage * 30));
101446
102317
  return {
101447
102318
  name: "Condition Quality",
@@ -101451,7 +102322,7 @@ class ConfidenceScorer {
101451
102322
  };
101452
102323
  }
101453
102324
  calculateOverall(factors, sampleCount) {
101454
- const baseScore = factors.reduce((sum, f2) => sum + f2.score * f2.weight, 0);
102325
+ const baseScore = factors.reduce((sum2, f2) => sum2 + f2.score * f2.weight, 0);
101455
102326
  const extraWeightMap = {
101456
102327
  1: 0.5,
101457
102328
  2: 0.25,
@@ -101505,35 +102376,35 @@ class OutlierDetector {
101505
102376
  const sorted = [...values].sort((a3, b3) => a3 - b3);
101506
102377
  const n4 = sorted.length;
101507
102378
  const midIndex = Math.floor(n4 / 2);
101508
- const median = n4 % 2 === 1 ? sorted[midIndex] ?? 0 : ((sorted[midIndex - 1] ?? 0) + (sorted[midIndex] ?? 0)) / 2;
101509
- const deviations = values.map((v2) => Math.abs(v2 - median));
102379
+ const median2 = n4 % 2 === 1 ? sorted[midIndex] ?? 0 : ((sorted[midIndex - 1] ?? 0) + (sorted[midIndex] ?? 0)) / 2;
102380
+ const deviations = values.map((v2) => Math.abs(v2 - median2));
101510
102381
  const sortedDev = [...deviations].sort((a3, b3) => a3 - b3);
101511
102382
  const mad = n4 % 2 === 1 ? sortedDev[midIndex] ?? 0 : ((sortedDev[midIndex - 1] ?? 0) + (sortedDev[midIndex] ?? 0)) / 2;
101512
- return { median, mad };
102383
+ return { median: median2, mad };
101513
102384
  }
101514
- modifiedZScore(value, median, mad) {
102385
+ modifiedZScore(value, median2, mad) {
101515
102386
  if (mad === 0)
101516
102387
  return 0;
101517
- return this.madToStdDev * (value - median) / mad;
102388
+ return this.madToStdDev * (value - median2) / mad;
101518
102389
  }
101519
102390
  detectEstimationOutliers(analyses) {
101520
102391
  const estimations = analyses.map((a3) => ({
101521
102392
  storyId: a3.story.id,
101522
- estimation: a3.story.estimation ?? 0
102393
+ estimation: numericStoryEstimate(a3.story.estimation) ?? 0
101523
102394
  })).filter((e2) => e2.estimation > 0);
101524
102395
  if (estimations.length < 2)
101525
102396
  return [];
101526
102397
  const values = estimations.map((e2) => e2.estimation);
101527
- const { median, mad } = this.calculateMAD(values);
102398
+ const { median: median2, mad } = this.calculateMAD(values);
101528
102399
  if (mad === 0)
101529
102400
  return [];
101530
102401
  const outliers = [];
101531
102402
  for (const e2 of estimations) {
101532
- const zScore = this.modifiedZScore(e2.estimation, median, mad);
102403
+ const zScore = this.modifiedZScore(e2.estimation, median2, mad);
101533
102404
  const absZ = Math.abs(zScore);
101534
102405
  if (absZ > this.zThreshold) {
101535
- const expectedLower = median - this.zThreshold * mad / this.madToStdDev;
101536
- const expectedUpper = median + this.zThreshold * mad / this.madToStdDev;
102406
+ const expectedLower = median2 - this.zThreshold * mad / this.madToStdDev;
102407
+ const expectedUpper = median2 + this.zThreshold * mad / this.madToStdDev;
101537
102408
  outliers.push({
101538
102409
  type: "estimation",
101539
102410
  storyId: e2.storyId,
@@ -101557,16 +102428,16 @@ class OutlierDetector {
101557
102428
  if (counts.length < 2)
101558
102429
  return [];
101559
102430
  const values = counts.map((c4) => c4.count);
101560
- const { median, mad } = this.calculateMAD(values);
102431
+ const { median: median2, mad } = this.calculateMAD(values);
101561
102432
  if (mad === 0)
101562
102433
  return [];
101563
102434
  const outliers = [];
101564
102435
  for (const c4 of counts) {
101565
- const zScore = this.modifiedZScore(c4.count, median, mad);
102436
+ const zScore = this.modifiedZScore(c4.count, median2, mad);
101566
102437
  const absZ = Math.abs(zScore);
101567
102438
  if (absZ > this.zThreshold) {
101568
- const expectedLower = median - this.zThreshold * mad / this.madToStdDev;
101569
- const expectedUpper = median + this.zThreshold * mad / this.madToStdDev;
102439
+ const expectedLower = median2 - this.zThreshold * mad / this.madToStdDev;
102440
+ const expectedUpper = median2 + this.zThreshold * mad / this.madToStdDev;
101570
102441
  outliers.push({
101571
102442
  type: "task-count",
101572
102443
  storyId: c4.storyId,
@@ -101626,7 +102497,7 @@ class OutlierDetector {
101626
102497
 
101627
102498
  // ../atomize-core/src/services/template/learned-template-product.ts
101628
102499
  class LearnedTemplateProductBuilder {
101629
- buildMergedTemplate(analyses, mergedTasks) {
102500
+ buildMergedTemplate(analyses, mergedTasks, estimationSource) {
101630
102501
  const taskDefinitions = mergedTasks.map((mt) => {
101631
102502
  const task = { ...mt.task };
101632
102503
  if (mt.learnedDependsOn && mt.learnedDependsOn.length > 0) {
@@ -101647,8 +102518,6 @@ class LearnedTemplateProductBuilder {
101647
102518
  normalizeLearnedTaskPercentages(taskDefinitions);
101648
102519
  const workItemTypes = [...new Set(analyses.map((a3) => a3.story.type))];
101649
102520
  const allTags = [...new Set(analyses.flatMap((a3) => a3.story.tags ?? []))];
101650
- const estimations = analyses.map((a3) => a3.story.estimation ?? 0).filter((estimation) => estimation > 0);
101651
- const avgEstimation = estimations.length > 0 ? Math.round(estimations.reduce((sum, estimation) => sum + estimation, 0) / estimations.length) : 0;
101652
102521
  const storyIds = analyses.map((a3) => a3.story.id).join(", ");
101653
102522
  return {
101654
102523
  version: "1.0",
@@ -101662,12 +102531,7 @@ class LearnedTemplateProductBuilder {
101662
102531
  tags: allTags.length > 0 ? { include: allTags } : undefined
101663
102532
  },
101664
102533
  tasks: taskDefinitions,
101665
- estimation: {
101666
- strategy: "percentage",
101667
- rounding: "none",
101668
- minimumTaskPoints: 0,
101669
- defaultParentEstimation: avgEstimation
101670
- }
102534
+ estimation: learnedEstimationConfig(analyses.map((a3) => estimationSample(a3.story, a3.tasks)), estimationSource)
101671
102535
  };
101672
102536
  }
101673
102537
  buildEmptyTemplate(story) {
@@ -101688,11 +102552,10 @@ class LearnedTemplateProductBuilder {
101688
102552
  }
101689
102553
  };
101690
102554
  }
101691
- buildSingleStoryTemplate(story, tasks) {
101692
- const storyEstimation = story.estimation || 0;
102555
+ buildSingleStoryTemplate(story, tasks, estimationSource) {
102556
+ const shares = taskEstimateShares(tasks.map((task) => numericStoryEstimate(task.estimation)));
101693
102557
  const taskDefinitions = tasks.map((task, index) => {
101694
- const taskEstimation = task.estimation || 0;
101695
- const estimationPercent = storyEstimation > 0 ? Math.round(taskEstimation / storyEstimation * 100) : 0;
102558
+ const estimationPercent = shares[index] ?? 0;
101696
102559
  return {
101697
102560
  id: this.generateTaskId(task.title, index),
101698
102561
  title: this.extractTitlePattern(task.title, story.title),
@@ -101716,16 +102579,12 @@ class LearnedTemplateProductBuilder {
101716
102579
  tags: story.tags?.length ? { include: story.tags } : undefined
101717
102580
  },
101718
102581
  tasks: taskDefinitions,
101719
- estimation: {
101720
- strategy: "percentage",
101721
- rounding: "none",
101722
- minimumTaskPoints: 0,
101723
- defaultParentEstimation: storyEstimation
101724
- }
102582
+ estimation: learnedEstimationConfig([estimationSample(story, tasks)], estimationSource)
101725
102583
  };
101726
102584
  }
101727
102585
  generateSuggestions(analyses, patterns, confidence, outliers) {
101728
102586
  return [
102587
+ ...this.suggestEstimationChecks(analyses),
101729
102588
  ...this.suggestConfidenceImprovements(analyses, confidence),
101730
102589
  ...this.suggestOutlierRemovals(outliers),
101731
102590
  ...this.suggestNamingImprovements(patterns),
@@ -101734,7 +102593,18 @@ class LearnedTemplateProductBuilder {
101734
102593
  ...this.suggestFilterImprovements(patterns)
101735
102594
  ];
101736
102595
  }
101737
- generateVariations(analyses, patterns, mergedTasks) {
102596
+ suggestEstimationChecks(analyses) {
102597
+ const learned = inferEstimation(analyses.map((a3) => estimationSample(a3.story, a3.tasks)));
102598
+ const table = learned.conversion?.table;
102599
+ if (!table)
102600
+ return [];
102601
+ return learned.lowConfidenceValues.map((value) => ({
102602
+ type: "adjust-estimation",
102603
+ message: `The conversion for "${value}" (${table[value]}) was learned from a single Story; check it before relying on it.`,
102604
+ severity: "info"
102605
+ }));
102606
+ }
102607
+ generateVariations(analyses, patterns, mergedTasks, estimationSource) {
101738
102608
  const confidenceScorer = new ConfidenceScorer;
101739
102609
  const variations = [];
101740
102610
  const coreTasks = mergedTasks.filter((mt) => {
@@ -101742,7 +102612,7 @@ class LearnedTemplateProductBuilder {
101742
102612
  return storyCount / analyses.length >= 0.6;
101743
102613
  });
101744
102614
  if (coreTasks.length > 0 && coreTasks.length !== mergedTasks.length) {
101745
- const coreTemplate = this.buildMergedTemplate(analyses, coreTasks);
102615
+ const coreTemplate = this.buildMergedTemplate(analyses, coreTasks, estimationSource);
101746
102616
  coreTemplate.name = "Core Tasks Template";
101747
102617
  coreTemplate.description = "Tasks appearing in 60%+ of analyzed stories";
101748
102618
  variations.push({
@@ -101753,7 +102623,7 @@ class LearnedTemplateProductBuilder {
101753
102623
  });
101754
102624
  }
101755
102625
  if (mergedTasks.length > 0) {
101756
- const fullTemplate = this.buildMergedTemplate(analyses, mergedTasks);
102626
+ const fullTemplate = this.buildMergedTemplate(analyses, mergedTasks, estimationSource);
101757
102627
  fullTemplate.name = "Comprehensive Template";
101758
102628
  fullTemplate.description = "All tasks found across analyzed stories";
101759
102629
  variations.push({
@@ -101876,6 +102746,23 @@ class LearnedTemplateProductBuilder {
101876
102746
  return suggestions;
101877
102747
  }
101878
102748
  }
102749
+ function estimationSample(story, tasks) {
102750
+ return {
102751
+ storyEstimate: story.estimation,
102752
+ taskEstimates: tasks.map((task) => numericStoryEstimate(task.estimation))
102753
+ };
102754
+ }
102755
+ function learnedEstimationConfig(samples, source) {
102756
+ const learned = inferEstimation(samples);
102757
+ return {
102758
+ strategy: "percentage",
102759
+ rounding: "none",
102760
+ minimumTaskEstimate: 0,
102761
+ ...source ? { source } : {},
102762
+ ...learned.conversion ? { conversion: learned.conversion } : {},
102763
+ ...learned.defaultStoryEstimate !== undefined ? { defaultParentEstimation: learned.defaultStoryEstimate } : {}
102764
+ };
102765
+ }
101879
102766
 
101880
102767
  // ../atomize-core/src/services/template/task-merger.ts
101881
102768
  class TaskMerger {
@@ -101969,15 +102856,15 @@ class TaskMerger {
101969
102856
  for (const value of values) {
101970
102857
  counts.set(value, (counts.get(value) ?? 0) + 1);
101971
102858
  }
101972
- let mostCommon = values[0] ?? 0;
102859
+ let mostCommon2 = values[0] ?? 0;
101973
102860
  let maxCount = 0;
101974
102861
  for (const [value, count] of counts) {
101975
- if (count > maxCount || count === maxCount && value > mostCommon) {
101976
- mostCommon = value;
102862
+ if (count > maxCount || count === maxCount && value > mostCommon2) {
102863
+ mostCommon2 = value;
101977
102864
  maxCount = count;
101978
102865
  }
101979
102866
  }
101980
- return mostCommon;
102867
+ return mostCommon2;
101981
102868
  }
101982
102869
  averagePriority(values) {
101983
102870
  if (values.length === 0)
@@ -102082,27 +102969,28 @@ class StoryLearner {
102082
102969
  this.learningSession = learningSession;
102083
102970
  this.productBuilder = productBuilder;
102084
102971
  }
102085
- async learnFromStory(storyId) {
102972
+ async learnFromStory(storyId, options = {}) {
102086
102973
  logger.info(`Learning template from story: ${storyId}`);
102087
- const story = await this.platform.getWorkItem(storyId);
102088
- if (!story) {
102974
+ const fetched = await this.platform.getWorkItem(storyId);
102975
+ if (!fetched) {
102089
102976
  throw new TemplateGenerationError(`Story ${storyId} not found`);
102090
102977
  }
102978
+ const story = withEstimationSource(fetched, options.estimationSource);
102091
102979
  const tasks = await this.platform.getChildren(storyId);
102092
102980
  if (!tasks || tasks.length === 0) {
102093
102981
  throw new TemplateGenerationError(`Story ${storyId} has no child tasks to learn from`);
102094
102982
  }
102095
102983
  logger.info(`Found ${tasks.length} tasks to analyze`);
102096
- const template = this.productBuilder.buildSingleStoryTemplate(story, tasks);
102984
+ const template = this.productBuilder.buildSingleStoryTemplate(story, tasks, options.estimationSource);
102097
102985
  logger.info("Template learned successfully");
102098
102986
  return template;
102099
102987
  }
102100
- async learnFromStories(storyIds) {
102988
+ async learnFromStories(storyIds, options = {}) {
102101
102989
  logger.info(`Learning template from ${storyIds.length} stories: ${storyIds.join(", ")}`);
102102
102990
  if (storyIds.length === 0) {
102103
102991
  throw new TemplateGenerationError("No story IDs provided");
102104
102992
  }
102105
- const results = await Promise.allSettled(storyIds.map((id) => this.analyzeStory(id)));
102993
+ const results = await Promise.allSettled(storyIds.map((id) => this.analyzeStory(id, options.estimationSource)));
102106
102994
  const analyses = [];
102107
102995
  const skipped = [];
102108
102996
  for (let i3 = 0;i3 < results.length; i3++) {
@@ -102134,9 +103022,9 @@ class StoryLearner {
102134
103022
  confidence,
102135
103023
  outliers
102136
103024
  } = this.learningSession.run(analyses);
102137
- const template = this.productBuilder.buildMergedTemplate(analyses, mergedTasks);
103025
+ const template = this.productBuilder.buildMergedTemplate(analyses, mergedTasks, options.estimationSource);
102138
103026
  const suggestions = this.productBuilder.generateSuggestions(analyses, patterns, confidence, outliers);
102139
- const variations = this.productBuilder.generateVariations(analyses, patterns, mergedTasks);
103027
+ const variations = this.productBuilder.generateVariations(analyses, patterns, mergedTasks, options.estimationSource);
102140
103028
  logger.info(`Multi-story learning complete: ${analyses.length} analyzed, ${skipped.length} skipped, confidence: ${confidence.level} (${confidence.overall}%)`);
102141
103029
  return {
102142
103030
  analyses,
@@ -102149,11 +103037,12 @@ class StoryLearner {
102149
103037
  outliers
102150
103038
  };
102151
103039
  }
102152
- async analyzeStory(storyId) {
102153
- const story = await this.platform.getWorkItem(storyId);
102154
- if (!story) {
103040
+ async analyzeStory(storyId, estimationSource) {
103041
+ const fetched = await this.platform.getWorkItem(storyId);
103042
+ if (!fetched) {
102155
103043
  throw new TemplateGenerationError(`Story ${storyId} not found`);
102156
103044
  }
103045
+ const story = withEstimationSource(fetched, estimationSource);
102157
103046
  const tasks = await this.platform.getChildren(storyId);
102158
103047
  const warnings = [];
102159
103048
  if (tasks.length === 0) {
@@ -102168,7 +103057,7 @@ class StoryLearner {
102168
103057
  if (tasks.length === 1) {
102169
103058
  warnings.push(`Story ${storyId} has only 1 task, which may not be representative`);
102170
103059
  }
102171
- const template = this.productBuilder.buildSingleStoryTemplate(story, tasks);
103060
+ const template = this.productBuilder.buildSingleStoryTemplate(story, tasks, estimationSource);
102172
103061
  return { story, tasks, template, warnings };
102173
103062
  }
102174
103063
  extractTitlePattern(taskTitle, storyTitle) {
@@ -102181,6 +103070,11 @@ class StoryLearner {
102181
103070
  return this.productBuilder.detectActivity(title, description);
102182
103071
  }
102183
103072
  }
103073
+ function withEstimationSource(story, source) {
103074
+ if (!source)
103075
+ return story;
103076
+ return { ...story, estimation: readStoryEstimate(story, { kind: "override", field: source }) };
103077
+ }
102184
103078
 
102185
103079
  // src/cli/commands/template/template-create.command.ts
102186
103080
  init_template_library();
@@ -102576,6 +103470,8 @@ mixins: string[] # optional — list of mixin references or file paths (e
102576
103470
  # Mixins contribute tasks only. Child tasks override mixin tasks with the same id.
102577
103471
  # Template names are NOT valid mixin sources.
102578
103472
 
103473
+ taskType: string # optional — work item type Tasks are created as (e.g. "Task", "Sub-task"). Omit to use the platform default.
103474
+
102579
103475
  filter: # required (unless inherited via extends)
102580
103476
  workItemTypes: string[] # e.g. ["User Story"], ["Bug"]
102581
103477
  states: string[] # e.g. ["New", "Active", "Approved"] — items must be in one of these states
@@ -102627,7 +103523,16 @@ tasks: # required, minimum 1 item
102627
103523
  estimation: # optional
102628
103524
  strategy: "percentage"
102629
103525
  rounding: "nearest" | "up" | "down" | "none" # default "none" — omit unless the user asks for rounding
102630
- minimumTaskPoints: number # optional, default 0
103526
+ normalize: "auto" | "always" | "never" # optional, default "auto" — set "never" only if the user wants percentages used exactly as written
103527
+ minimumTaskEstimate: number # optional, default 0 — minimum per task, in the Task's unit (hours on Azure DevOps)
103528
+ source: string # optional — Story field reference name holding the Story Estimate (e.g. "Custom.TShirtSize"). Omit to use the platform default (story points / effort / size).
103529
+ conversion: # optional — Story Estimate → Task-unit total before the split. Set exactly ONE of factor or table.
103530
+ factor: number # e.g. 4 when 1 point = 4 hours
103531
+ table: # exact value → amount, e.g. { S: 2, M: 4, L: 5, XL: 13 }
103532
+ multipliers: boolean # optional, only with a table of size keys: "0.3XL" = 0.3 × XL
103533
+ targetFields: string[] # optional — Task fields that receive the estimate, replacing the platform defaults. Rarely needed.
103534
+ ifParentHasNoEstimation: "warn" | "skip" | "use-default" # optional, default "warn" (tasks get blank estimates)
103535
+ defaultParentEstimation: number | string # optional — Story Estimate to assume with use-default, e.g. 5 or "M"
102631
103536
 
102632
103537
  validation: # optional
102633
103538
  minTasks: number
@@ -102672,6 +103577,11 @@ CONSTRAINTS (you must follow these):
102672
103577
  estimationPercentCondition:
102673
103578
  - condition: { field: priority, operator: lte, value: 2 }
102674
103579
  percent: 35 # override for critical bugs (priority 1 or 2)
103580
+ 8. ESTIMATION FIELDS: only set taskType, estimation.source, estimation.conversion or estimation.targetFields when the user describes non-default sizing (for example t-shirt sizes, a custom size field, or "1 point = 4 hours"), or when the workspace context lists an estimation field that matches the description.
103581
+ - Use field reference names and work item types exactly as given in the workspace context; never invent them.
103582
+ - With a conversion table on a picklist source, use the picklist's allowed values exactly as keys and cover every one.
103583
+ - For a numeric scale where hours grow faster than points, use a table with numeric keys instead of a factor.
103584
+ - Conditions on estimation compare the raw Story Estimate: with size categories use equals/not-equals (e.g. value: "L"), never gt/lt/gte/lte.
102675
103585
 
102676
103586
  EXAMPLES:
102677
103587
 
@@ -102890,7 +103800,7 @@ ${description}`;
102890
103800
  Observed patterns from this user's Azure DevOps workspace:
102891
103801
  ${groundingContext}
102892
103802
 
102893
- Use these patterns as concrete examples when choosing task names, estimation percentages, and conditions. Adapt them to fit the description above — do not copy verbatim.
103803
+ Use these patterns as concrete examples when choosing task names, estimation percentages, and conditions. Adapt them to fit the description above — do not copy verbatim. If the context has an "estimation" section, it lists the platform's default estimation fields and the Story fields that can hold a Story Estimate, with their allowed values; follow rule 8 when using them.
102894
103804
  ---`;
102895
103805
  }
102896
103806
  if (prevErrors && prevErrors.length > 0) {
@@ -103189,7 +104099,7 @@ var STORY_FIELDS = [
103189
104099
  { label: "Tags (array)", value: "tags" },
103190
104100
  { label: "Title", value: "title" },
103191
104101
  { label: "State", value: "state" },
103192
- { label: "Estimation (points)", value: "estimation" },
104102
+ { label: "Story Estimate (raw value, e.g. points or size)", value: "estimation" },
103193
104103
  { label: "Priority", value: "priority" },
103194
104104
  { label: "Description", value: "description" },
103195
104105
  { label: "Assigned To", value: "assignedTo" },
@@ -103581,7 +104491,7 @@ Filter Configuration:`));
103581
104491
  function displayTasksSummary(template) {
103582
104492
  output4.print(source_default.bold(`
103583
104493
  Tasks (${template.tasks.length}):`));
103584
- const totalEstimation = template.tasks.reduce((sum, task) => sum + (task.estimationPercent || 0), 0);
104494
+ const totalEstimation = template.tasks.reduce((sum2, task) => sum2 + (task.estimationPercent || 0), 0);
103585
104495
  template.tasks.forEach((task, index) => {
103586
104496
  const percent = task.estimationPercent || 0;
103587
104497
  const bar = "■".repeat(Math.round(percent / 5));
@@ -103679,8 +104589,9 @@ Editing Tasks
103679
104589
  Editing Estimation Settings
103680
104590
  `));
103681
104591
  const { configureEstimation: configureEstimation2 } = await Promise.resolve().then(() => (init_template_wizard_helper_command(), exports_template_wizard_helper_command));
103682
- const estimation = await configureEstimation2(template.estimation);
104592
+ const { estimation, taskType } = await configureEstimation2(template.estimation, ctx.estimationCtx ?? { storyFields: ctx.storyFieldSchemas, workItemTypes: ctx.filterCtx.workItemTypes }, template.taskType);
103683
104593
  template.estimation = estimation;
104594
+ template.taskType = taskType;
103684
104595
  }).with("validation", async () => {
103685
104596
  output4.print(source_default.cyan(`
103686
104597
  Editing Validation Rules
@@ -103745,7 +104656,7 @@ function showStepHint(stepName) {
103745
104656
  const hints = {
103746
104657
  filter: "Tip: Use filters to select which work items this template applies to",
103747
104658
  tasks: "Tip: Break work into clear, actionable tasks. Estimation percentages will be normalized to 100%. If you plan on set dependencies, please set task IDs.",
103748
- estimation: "Tip: Choose how story points will be calculated and rounded",
104659
+ estimation: "Tip: Choose how Story Estimates turn into Task hours and where they are written",
103749
104660
  validation: "Tip: Add constraints to ensure templates are used correctly",
103750
104661
  metadata: "Tip: Metadata helps others understand when to use this template"
103751
104662
  };
@@ -103815,7 +104726,7 @@ async function shouldAddMoreTasks(taskCounter) {
103815
104726
  }));
103816
104727
  }
103817
104728
  async function handleEstimationNormalization(tasks) {
103818
- const totalEstimation = tasks.reduce((sum, task) => sum + (task.estimationPercent || 0), 0);
104729
+ const totalEstimation = tasks.reduce((sum2, task) => sum2 + (task.estimationPercent || 0), 0);
103819
104730
  if (totalEstimation === 100) {
103820
104731
  output4.print(source_default.green("✓ Total estimation is 100%"));
103821
104732
  return;
@@ -103943,6 +104854,7 @@ Customize Template
103943
104854
  const adapter = await createAzureDevOpsAdapter(profile);
103944
104855
  const metadataReader = requireProjectMetadataReader(adapter);
103945
104856
  const savedQueryReader = requireSavedQueryReader(adapter);
104857
+ const estimationDefaults2 = adapter.getEstimationDefaults();
103946
104858
  const [
103947
104859
  taskSchemas,
103948
104860
  liveWorkItemTypes,
@@ -103951,7 +104863,7 @@ Customize Template
103951
104863
  liveTeams,
103952
104864
  liveSavedQueries
103953
104865
  ] = await Promise.all([
103954
- metadataReader.getFieldSchemas("Task"),
104866
+ metadataReader.getFieldSchemas(template.taskType ?? estimationDefaults2.taskWorkItemType),
103955
104867
  metadataReader.getWorkItemTypes(),
103956
104868
  metadataReader.getAreaPaths(),
103957
104869
  metadataReader.getIterationPaths(),
@@ -103960,6 +104872,7 @@ Customize Template
103960
104872
  ]);
103961
104873
  return {
103962
104874
  metadataReader,
104875
+ estimationDefaults: estimationDefaults2,
103963
104876
  fieldSchemas: taskSchemas,
103964
104877
  filterCtx: {
103965
104878
  workItemTypes: liveWorkItemTypes,
@@ -104007,6 +104920,7 @@ Editing Name & Description
104007
104920
  let filterCtx;
104008
104921
  let fieldSchemas;
104009
104922
  let adapterForWizard;
104923
+ let estimationDefaults;
104010
104924
  try {
104011
104925
  const conn = await connectionPromise;
104012
104926
  if (!wasAlreadyConnected)
@@ -104014,6 +104928,7 @@ Editing Name & Description
104014
104928
  filterCtx = conn.filterCtx;
104015
104929
  fieldSchemas = conn.fieldSchemas;
104016
104930
  adapterForWizard = conn.metadataReader;
104931
+ estimationDefaults = conn.estimationDefaults;
104017
104932
  } catch (err) {
104018
104933
  if (!wasAlreadyConnected)
104019
104934
  connectSpinner.stop("Connection failed");
@@ -104025,6 +104940,12 @@ Editing Name & Description
104025
104940
  }
104026
104941
  let storyFieldSchemas = [];
104027
104942
  let storySchemasFetched = false;
104943
+ const estimationContext = async () => ({
104944
+ storyFields: storyFieldSchemas.length ? storyFieldSchemas : await adapterForWizard.getFieldSchemas(),
104945
+ workItemTypes: filterCtx.workItemTypes,
104946
+ defaults: estimationDefaults,
104947
+ getTaskFields: (type) => adapterForWizard.getFieldSchemas(type)
104948
+ });
104028
104949
  for (const section of ["filter", "tasks", "estimation", "validation", "metadata"]) {
104029
104950
  if (!sections.includes(section))
104030
104951
  continue;
@@ -104062,7 +104983,14 @@ Editing Tasks
104062
104983
  output5.print(source_default.cyan(`
104063
104984
  Editing Estimation Settings
104064
104985
  `));
104065
- template.estimation = await configureEstimation(template.estimation);
104986
+ if (!storySchemasFetched) {
104987
+ const wit = template.filter.workItemTypes?.[0];
104988
+ storyFieldSchemas = wit ? await adapterForWizard.getFieldSchemas(wit) : [];
104989
+ storySchemasFetched = true;
104990
+ }
104991
+ const { estimation, taskType } = await configureEstimation(template.estimation, await estimationContext(), template.taskType);
104992
+ template.estimation = estimation;
104993
+ template.taskType = taskType;
104066
104994
  break;
104067
104995
  }
104068
104996
  case "validation": {
@@ -104103,7 +105031,8 @@ Editing Metadata
104103
105031
  filterCtx,
104104
105032
  fieldSchemas,
104105
105033
  storyFieldSchemas,
104106
- workItemType: template.filter.workItemTypes?.[0]
105034
+ workItemType: template.filter.workItemTypes?.[0],
105035
+ estimationCtx: await estimationContext()
104107
105036
  };
104108
105037
  const confirmed = await previewTemplate(template, wizardCtx);
104109
105038
  if (!confirmed) {
@@ -104421,7 +105350,7 @@ async function handlePreviewLoop(template, options) {
104421
105350
  // src/cli/commands/template/template-create.command.ts
104422
105351
  init_template_wizard_helper_command();
104423
105352
  var defaultOutput = createCommandOutput(resolveCommandOutputPolicy({}));
104424
- var templateCreateCommand = new Command("create").description("Create a new template interactively").option("--type <type>", "Create as template or mixin").option("--from <name>", "Start from an existing template").option("--from-stories <ids>", "Learn template from multiple stories (comma-separated IDs)").option("-p, --platform <platform>", "Platform to use", "azure-devops").option("--profile <name>", "Connect to ADO using a named profile for field suggestions (uses default profile if omitted)").option("--scratch", "Create from scratch (skip mode selection)").option("--ai", "Use AI-assisted generation (describe the template in natural language)").option("--ground", "Ground AI generation with patterns from your Azure DevOps workspace").option("--save-as <name>", "Name to save the template under").option("--open", "Open the saved Atomize YAML file in a supported editor", false).option("-q, --quiet", "Suppress non-essential output", false).action(async (options) => {
105353
+ var templateCreateCommand = new Command("create").description("Create a new template interactively").option("--type <type>", "Create as template or mixin").option("--from <name>", "Start from an existing template").option("--from-stories <ids>", "Learn template from multiple stories (comma-separated IDs)").option("--estimation-source <field>", "With --from-stories, read each Story Estimate from this field reference name (e.g. Custom.TShirtSize) and write it as estimation.source").option("-p, --platform <platform>", "Platform to use", "azure-devops").option("--profile <name>", "Connect to ADO using a named profile for field suggestions (uses default profile if omitted)").option("--scratch", "Create from scratch (skip mode selection)").option("--ai", "Use AI-assisted generation (describe the template in natural language)").option("--ground", "Ground AI generation with patterns from your Azure DevOps workspace").option("--save-as <name>", "Name to save the template under").option("--open", "Open the saved Atomize YAML file in a supported editor", false).option("-q, --quiet", "Suppress non-essential output", false).action(async (options) => {
104425
105354
  const output9 = createCommandOutput(resolveCommandOutputPolicy({ quiet: options.quiet, verbose: false }));
104426
105355
  try {
104427
105356
  output9.intro(" Atomize — Template Creator");
@@ -104680,7 +105609,7 @@ async function createFromStories(options) {
104680
105609
  const learner = new StoryLearner(learningPlatform);
104681
105610
  const learnSpinner = createManagedSpinner();
104682
105611
  learnSpinner.start(`Learning from ${storyIds.length} stories...`);
104683
- const result = await learner.learnFromStories(storyIds);
105612
+ const result = await learner.learnFromStories(storyIds, { estimationSource: options.estimationSource });
104684
105613
  learnSpinner.stop(`Analyzed ${result.analyses.length} stories ✓`);
104685
105614
  displayMultiStoryResults(result);
104686
105615
  if (result.variations.length > 1) {
@@ -104776,8 +105705,9 @@ Create Template from Scratch
104776
105705
  const adapter = await createAzureDevOpsAdapter(profile);
104777
105706
  const metadataReader = requireProjectMetadataReader(adapter);
104778
105707
  const savedQueryReader = requireSavedQueryReader(adapter);
105708
+ const estimationDefaults2 = adapter.getEstimationDefaults();
104779
105709
  const [taskSchemas, liveWorkItemTypes, liveAreaPaths, liveIterationPaths, liveTeams, liveSavedQueries] = await Promise.all([
104780
- metadataReader.getFieldSchemas("Task"),
105710
+ metadataReader.getFieldSchemas(estimationDefaults2.taskWorkItemType),
104781
105711
  metadataReader.getWorkItemTypes(),
104782
105712
  metadataReader.getAreaPaths(),
104783
105713
  metadataReader.getIterationPaths(),
@@ -104786,6 +105716,7 @@ Create Template from Scratch
104786
105716
  ]);
104787
105717
  return {
104788
105718
  metadataReader,
105719
+ estimationDefaults: estimationDefaults2,
104789
105720
  fieldSchemas: taskSchemas,
104790
105721
  filterCtx: {
104791
105722
  workItemTypes: liveWorkItemTypes,
@@ -104812,6 +105743,7 @@ Create Template from Scratch
104812
105743
  let filterCtx;
104813
105744
  let fieldSchemas;
104814
105745
  let adapterForWizard;
105746
+ let estimationDefaults;
104815
105747
  const wasAlreadyConnected = connectionSettled;
104816
105748
  const connectSpinner = createManagedSpinner();
104817
105749
  if (!wasAlreadyConnected)
@@ -104823,6 +105755,7 @@ Create Template from Scratch
104823
105755
  filterCtx = conn.filterCtx;
104824
105756
  fieldSchemas = conn.fieldSchemas;
104825
105757
  adapterForWizard = conn.metadataReader;
105758
+ estimationDefaults = conn.estimationDefaults;
104826
105759
  } catch (err) {
104827
105760
  if (!wasAlreadyConnected)
104828
105761
  connectSpinner.stop("Connection failed");
@@ -104870,9 +105803,15 @@ Create Template from Scratch
104870
105803
  printStep(source_default.blue(`
104871
105804
  [${currentStep}/${totalSteps}] Estimation Settings`));
104872
105805
  printStep(source_default.gray("████░░"));
104873
- printStep(source_default.gray(`Tip: Choose how story points will be calculated and rounded
105806
+ printStep(source_default.gray(`Tip: Choose how Story Estimates turn into Task hours and where they are written
104874
105807
  `));
104875
- const estimation = await configureEstimation();
105808
+ const estimationCtx = {
105809
+ storyFields: storyFieldSchemas.length ? storyFieldSchemas : await adapterForWizard.getFieldSchemas(),
105810
+ workItemTypes: filterCtx.workItemTypes,
105811
+ defaults: estimationDefaults,
105812
+ getTaskFields: (type) => adapterForWizard.getFieldSchemas(type)
105813
+ };
105814
+ const { estimation, taskType } = await configureEstimation(undefined, estimationCtx);
104876
105815
  currentStep++;
104877
105816
  printStep(source_default.blue(`
104878
105817
  [${currentStep}/${totalSteps}] Validation Rules (Optional)`));
@@ -104912,6 +105851,7 @@ Create Template from Scratch
104912
105851
  created: new Date().toISOString(),
104913
105852
  filter: filterConfig,
104914
105853
  tasks,
105854
+ ...taskType ? { taskType } : {},
104915
105855
  estimation,
104916
105856
  validation,
104917
105857
  metadata
@@ -104925,7 +105865,8 @@ Create Template from Scratch
104925
105865
  filterCtx,
104926
105866
  fieldSchemas,
104927
105867
  storyFieldSchemas,
104928
- workItemType
105868
+ workItemType,
105869
+ estimationCtx
104929
105870
  };
104930
105871
  const confirmed = await previewTemplate(template, wizardCtx);
104931
105872
  if (!confirmed) {
@@ -105711,7 +106652,9 @@ function createValidateCommandDeps(depsOptions) {
105711
106652
  strict: options.strict === true,
105712
106653
  platform: {
105713
106654
  getFieldSchemas: (workItemType) => metadataReader.getFieldSchemas(workItemType),
105714
- listSavedQueries: (folder) => savedQueryReader.listSavedQueries(folder)
106655
+ listSavedQueries: (folder) => savedQueryReader.listSavedQueries(folder),
106656
+ getEstimationDefaults: () => adapter.getEstimationDefaults(),
106657
+ getWorkItemTypes: adapter.getWorkItemTypes?.bind(adapter)
105715
106658
  }
105716
106659
  },
105717
106660
  connectionWarnings: []