@sppg2001/atomize 4.0.1 → 4.2.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,35 @@ var init_errors = __esm(() => {
178
178
  };
179
179
  });
180
180
 
181
+ // ../atomize-schema/src/estimate-format.ts
182
+ function formatEstimateValue(value) {
183
+ return String(Math.round(value * 100) / 100);
184
+ }
185
+
186
+ // ../atomize-schema/src/estimation-fields.ts
187
+ function matchesFieldPattern(referenceName, patterns = []) {
188
+ return patterns.some((pattern) => pattern.endsWith(".*") ? referenceName.startsWith(pattern.slice(0, -1)) : referenceName === pattern);
189
+ }
190
+ function isPlatformExcluded(field, rules) {
191
+ return field.referenceName.startsWith("System.") || matchesFieldPattern(field.referenceName, rules?.nonEstimateFields);
192
+ }
193
+ function isEstimateSourceField(field, rules) {
194
+ if (field.isReadOnly)
195
+ return false;
196
+ if (rules?.storyEstimateFields?.includes(field.referenceName))
197
+ return true;
198
+ if (isPlatformExcluded(field, rules))
199
+ return false;
200
+ return field.type === "integer" || field.type === "decimal" || field.type === "string" && !field.isMultiline;
201
+ }
202
+ function isEstimateTargetField(field, rules) {
203
+ return !field.isReadOnly && !field.isPicklist && (field.type === "integer" || field.type === "decimal") && !isPlatformExcluded(field, rules);
204
+ }
205
+ function sortEstimateFields(fields) {
206
+ const custom = (field) => Number(field.isCustom ?? field.referenceName.startsWith("Custom."));
207
+ return [...fields].sort((a, b) => custom(b) - custom(a) || a.name.localeCompare(b.name));
208
+ }
209
+
181
210
  // ../atomize-schema/src/estimation-normalizer.ts
182
211
  function normalizeEstimationPercentages(items, options = {}) {
183
212
  const { skipIfAlreadyNormalized = true, tolerance = 0.01, targetTotal = 100 } = options;
@@ -20065,27 +20094,86 @@ var init_zod = __esm(() => {
20065
20094
  });
20066
20095
 
20067
20096
  // ../atomize-schema/src/schema.ts
20097
+ function lookupTableEstimate(value, table, multipliers = false) {
20098
+ const text = String(value).trim();
20099
+ if (Object.hasOwn(table, text))
20100
+ return table[text];
20101
+ if (!multipliers)
20102
+ return;
20103
+ const candidates = Object.keys(table).filter((key) => key !== "" && text.endsWith(key)).sort((a, b) => b.length - a.length);
20104
+ for (const key of candidates) {
20105
+ const prefix = text.slice(0, text.length - key.length).trim();
20106
+ if (NUMBER_PATTERN.test(prefix))
20107
+ return Number(prefix) * table[key];
20108
+ }
20109
+ return;
20110
+ }
20111
+ function isConvertibleStoryEstimate(value, conversion) {
20112
+ if (conversion?.table)
20113
+ return lookupTableEstimate(value, conversion.table, conversion.multipliers) !== undefined;
20114
+ return typeof value === "number" || value.trim() !== "" && Number.isFinite(Number(value));
20115
+ }
20068
20116
  function ownPercentRange(task) {
20069
20117
  const candidates = [task.estimationPercent ?? 0, ...task.estimationPercentCondition?.map((rule) => rule.percent) ?? []];
20070
20118
  return [Math.min(...candidates), Math.max(...candidates)];
20071
20119
  }
20120
+ function copyCount(task) {
20121
+ return task.repeat ?? 1;
20122
+ }
20123
+ function generatedTaskCount(tasks) {
20124
+ return tasks.reduce((sum, task) => sum + copyCount(task), 0);
20125
+ }
20126
+ function validateRepeatedTasks(tasks, ctx, maxRepeat) {
20127
+ const repeatedIds = new Set(tasks.filter((t) => t.repeat !== undefined && t.id).map((t) => t.id));
20128
+ tasks.forEach((task, index) => {
20129
+ if (maxRepeat !== undefined && task.repeat !== undefined && task.repeat > maxRepeat) {
20130
+ ctx.addIssue({
20131
+ code: "custom",
20132
+ path: ["tasks", index, "repeat"],
20133
+ message: `Task repeats ${task.repeat} times, but the maximum is ${maxRepeat}. Lower repeat or raise validation.maxRepeat.`,
20134
+ params: { code: "REPEAT_EXCEEDS_MAX", maxRepeat }
20135
+ });
20136
+ }
20137
+ if (task.repeat !== undefined && task.dependsOn?.length) {
20138
+ ctx.addIssue({
20139
+ code: "custom",
20140
+ path: ["tasks", index, "dependsOn"],
20141
+ message: "A repeated task cannot have dependsOn. Remove repeat or dependsOn from this task.",
20142
+ params: { code: "REPEAT_WITH_DEPENDENCY" }
20143
+ });
20144
+ }
20145
+ task.dependsOn?.forEach((depId) => {
20146
+ if (repeatedIds.has(depId)) {
20147
+ ctx.addIssue({
20148
+ code: "custom",
20149
+ path: ["tasks", index, "dependsOn"],
20150
+ message: `Task depends on "${depId}", which is repeated. A task cannot depend on a repeated task.`,
20151
+ params: { code: "DEPENDS_ON_REPEATED_TASK", taskId: depId }
20152
+ });
20153
+ }
20154
+ });
20155
+ });
20156
+ }
20072
20157
  function validateTaskConstraints(v, tasks, ctx) {
20073
- if (v?.minTasks !== undefined && tasks.length < v.minTasks) {
20074
- const needed = v.minTasks - tasks.length;
20158
+ const count = generatedTaskCount(tasks);
20159
+ const hasRepeats = tasks.some((task) => copyCount(task) > 1);
20160
+ const counted = hasRepeats ? " (counting each repeated copy)" : "";
20161
+ if (v?.minTasks !== undefined && count < v.minTasks) {
20162
+ const needed = v.minTasks - count;
20075
20163
  ctx.addIssue({
20076
20164
  code: "custom",
20077
20165
  path: ["tasks"],
20078
- message: `Template has ${tasks.length} task(s), but minimum is ${v.minTasks}. Add ${needed} more task(s).`,
20079
- params: { code: "TOO_FEW_TASKS" }
20166
+ message: `Template has ${count} task(s)${counted}, but minimum is ${v.minTasks}. Add ${needed} more task(s).`,
20167
+ params: { code: "TOO_FEW_TASKS", count, limit: v.minTasks, hasRepeats }
20080
20168
  });
20081
20169
  }
20082
- if (v?.maxTasks !== undefined && tasks.length > v.maxTasks) {
20083
- const excess = tasks.length - v.maxTasks;
20170
+ if (v?.maxTasks !== undefined && count > v.maxTasks) {
20171
+ const excess = count - v.maxTasks;
20084
20172
  ctx.addIssue({
20085
20173
  code: "custom",
20086
20174
  path: ["tasks"],
20087
- message: `Template has ${tasks.length} task(s), but maximum is ${v.maxTasks}. Remove ${excess} task(s) or increase maxTasks.`,
20088
- params: { code: "TOO_MANY_TASKS" }
20175
+ message: `Template has ${count} task(s)${counted}, but maximum is ${v.maxTasks}. Remove ${excess} task(s) or increase maxTasks.`,
20176
+ params: { code: "TOO_MANY_TASKS", count, limit: v.maxTasks, hasRepeats }
20089
20177
  });
20090
20178
  }
20091
20179
  }
@@ -20107,14 +20195,14 @@ function validateUniqueTaskIds(tasks, ctx) {
20107
20195
  });
20108
20196
  });
20109
20197
  }
20110
- function validateEstimationConstraints(v, totalPercent, maxTotalPercent, ctx) {
20198
+ function validateEstimationConstraints(v, totalPercent, maxTotalPercent, hasRepeats, ctx) {
20111
20199
  if (v?.totalEstimationMustBe !== undefined) {
20112
20200
  if (v.totalEstimationMustBe < totalPercent || v.totalEstimationMustBe > maxTotalPercent) {
20113
20201
  ctx.addIssue({
20114
20202
  code: "custom",
20115
20203
  path: ["tasks"],
20116
20204
  message: `Total estimation is ${totalPercent}%, but must be ${v.totalEstimationMustBe}%.`,
20117
- params: { code: "INVALID_TOTAL_ESTIMATION" }
20205
+ params: { code: "INVALID_TOTAL_ESTIMATION", hasRepeats }
20118
20206
  });
20119
20207
  }
20120
20208
  } else if (v?.totalEstimationRange) {
@@ -20124,7 +20212,7 @@ function validateEstimationConstraints(v, totalPercent, maxTotalPercent, ctx) {
20124
20212
  code: "custom",
20125
20213
  path: ["tasks"],
20126
20214
  message: `Total estimation is ${totalPercent}%, but must be between ${min}% and ${max}%.`,
20127
- params: { code: "INVALID_ESTIMATION_RANGE" }
20215
+ params: { code: "INVALID_ESTIMATION_RANGE", hasRepeats }
20128
20216
  });
20129
20217
  }
20130
20218
  }
@@ -20162,7 +20250,7 @@ function reportCircularDependencies(tasks, taskIndexById, ctx) {
20162
20250
  });
20163
20251
  }
20164
20252
  }
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";
20253
+ 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, DEFAULT_MAX_REPEAT = 20, MixinTemplateSchema, CURRENT_ITERATION = "@CurrentIteration", TEAM_AREAS = "@TeamAreas";
20166
20254
  var init_schema = __esm(() => {
20167
20255
  init_zod();
20168
20256
  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 +20307,7 @@ var init_schema = __esm(() => {
20219
20307
  });
20220
20308
  FilterCriteriaSchema = exports_external.object({
20221
20309
  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(),
20310
+ 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
20311
  states: exports_external.array(exports_external.string()).describe("Story states to target (e.g. ['Active', 'New']).").optional(),
20224
20312
  statesExclude: exports_external.array(exports_external.string()).describe("Story states to exclude from selection.").optional(),
20225
20313
  statesWereEver: exports_external.array(exports_external.string()).describe("Include stories that were ever in any of these states.").optional(),
@@ -20256,6 +20344,7 @@ var init_schema = __esm(() => {
20256
20344
  tags: exports_external.array(exports_external.string()).describe("Tags applied to the generated task.").optional(),
20257
20345
  condition: ConditionSchema.describe("Condition evaluated against the parent story. If it does not match, this task is skipped.").optional(),
20258
20346
  dependsOn: exports_external.array(exports_external.string()).describe("IDs of tasks this task depends on. Atomize creates predecessor dependency links between these tasks.").optional(),
20347
+ repeat: exports_external.number().int("Repeat count must be a whole number").min(1, "Repeat count must be at least 1").describe("Number of identical Tasks to generate from this definition (default 1). Each copy's title gets its position, e.g. 'Review (2)'. Cannot be combined with dependsOn.").optional(),
20259
20348
  assignTo: exports_external.string().describe("Email address or alias to assign this task to.").optional(),
20260
20349
  priority: exports_external.number().describe("Priority value for the generated task.").optional(),
20261
20350
  activity: exports_external.string().describe("Azure DevOps activity type for this task (e.g. 'Development', 'Testing', 'Design').").optional(),
@@ -20276,14 +20365,42 @@ var init_schema = __esm(() => {
20276
20365
  return;
20277
20366
  })
20278
20367
  }).strict();
20368
+ NUMBER_PATTERN = /^(?:\d+(?:\.\d+)?|\.\d+)$/;
20369
+ EstimationConversionSchema = exports_external.object({
20370
+ 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(),
20371
+ 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(),
20372
+ 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()
20373
+ }).strict().refine((conversion) => conversion.factor === undefined !== (conversion.table === undefined), {
20374
+ message: "conversion must set exactly one of factor or table"
20375
+ }).refine((conversion) => !conversion.multipliers || conversion.table !== undefined, {
20376
+ message: "conversion.multipliers requires conversion.table",
20377
+ path: ["multipliers"]
20378
+ }).refine((conversion) => !conversion.multipliers || !conversion.table || !Object.keys(conversion.table).every((key) => NUMBER_PATTERN.test(key)), {
20379
+ message: "conversion.multipliers cannot be used when every table key is numeric: '25' would be ambiguous between the key 25 and 2 × 5",
20380
+ path: ["multipliers"]
20381
+ }).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
20382
  EstimationConfigSchema = exports_external.object({
20280
20383
  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(),
20384
+ 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(),
20385
+ conversion: EstimationConversionSchema.optional(),
20386
+ 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(),
20387
+ 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
20388
  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();
20389
+ minimumTaskEstimate: exports_external.number().describe("Minimum Task Estimate any single task can receive after rounding, in the Task's unit.").optional(),
20390
+ minimumTaskPoints: exports_external.number().describe("Deprecated: use minimumTaskEstimate. Still honoured when minimumTaskEstimate is absent.").meta({ deprecated: true }).optional(),
20391
+ 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(),
20392
+ 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()
20393
+ }).strict().superRefine((config2, ctx) => {
20394
+ if (config2.ifParentHasNoEstimation !== "use-default" || config2.defaultParentEstimation === undefined)
20395
+ return;
20396
+ if (isConvertibleStoryEstimate(config2.defaultParentEstimation, config2.conversion))
20397
+ return;
20398
+ ctx.addIssue({
20399
+ code: "custom",
20400
+ path: ["defaultParentEstimation"],
20401
+ 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`
20402
+ });
20403
+ });
20287
20404
  ValidationModeSchema = exports_external.enum(["strict", "lenient"]).describe("'strict' fails on any validation warning; 'lenient' allows warnings to pass.");
20288
20405
  ValidationConfigSchema = exports_external.object({
20289
20406
  mode: ValidationModeSchema.describe("'strict' fails on any validation warning; 'lenient' allows warnings to pass.").optional(),
@@ -20294,6 +20411,7 @@ var init_schema = __esm(() => {
20294
20411
  }).strict().describe("Sum of all non-conditional task percentages must fall within this range; bounds may exceed 100 when tasks span roles.").optional(),
20295
20412
  minTasks: exports_external.number().describe("Minimum number of tasks the template must define.").optional(),
20296
20413
  maxTasks: exports_external.number().describe("Maximum number of tasks the template may define.").optional(),
20414
+ maxRepeat: exports_external.number().int("Maximum repeat count must be a whole number").min(1, "Maximum repeat count must be at least 1").describe("Highest 'repeat' any task in this template may use (default 20). Raise it for a template that genuinely needs more copies.").optional(),
20297
20415
  taskEstimationRange: exports_external.object({
20298
20416
  min: exports_external.number().describe("Minimum allowed estimation percentage for a single task."),
20299
20417
  max: exports_external.number().describe("Maximum allowed estimation percentage for a single task.")
@@ -20326,6 +20444,7 @@ var init_schema = __esm(() => {
20326
20444
  lastModified: exports_external.string().describe("ISO 8601 date of the last modification.").optional(),
20327
20445
  filter: FilterCriteriaSchema.describe("Criteria used to select which stories this template applies to."),
20328
20446
  tasks: exports_external.array(TaskDefinitionSchema).min(1, "At least one task is required").describe("Task definitions that will be generated from this template."),
20447
+ 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
20448
  estimation: EstimationConfigSchema.describe("Global estimation configuration for all tasks in this template.").optional(),
20330
20449
  validation: ValidationConfigSchema.describe("Validation rules applied when this template is used.").optional(),
20331
20450
  metadata: MetadataSchema.describe("Additional metadata for template discovery and documentation.").optional(),
@@ -20347,12 +20466,13 @@ var init_schema = __esm(() => {
20347
20466
  let maxTotalPercent = 0;
20348
20467
  for (const task of tasks) {
20349
20468
  const [ownMin, ownMax] = ownPercentRange(task);
20350
- totalPercent += task.condition ? 0 : ownMin;
20351
- maxTotalPercent += ownMax;
20469
+ const copies = copyCount(task);
20470
+ totalPercent += task.condition ? 0 : ownMin * copies;
20471
+ maxTotalPercent += ownMax * copies;
20352
20472
  }
20353
20473
  const taskIds = new Set(tasks.map((t) => t.id).filter(Boolean));
20354
20474
  validateUniqueTaskIds(tasks, ctx);
20355
- validateEstimationConstraints(v, totalPercent, maxTotalPercent, ctx);
20475
+ validateEstimationConstraints(v, totalPercent, maxTotalPercent, tasks.some((t) => copyCount(t) > 1), ctx);
20356
20476
  validateTaskConstraints(v, tasks, ctx);
20357
20477
  validateRequiredTasks(v, tasks, ctx);
20358
20478
  const availableIds = Array.from(taskIds);
@@ -20361,6 +20481,7 @@ var init_schema = __esm(() => {
20361
20481
  if (t.id)
20362
20482
  taskIndexById.set(t.id, i);
20363
20483
  });
20484
+ validateRepeatedTasks(tasks, ctx, v?.maxRepeat ?? DEFAULT_MAX_REPEAT);
20364
20485
  tasks.forEach((task, index) => {
20365
20486
  task.dependsOn?.forEach((depId) => {
20366
20487
  if (!taskIds.has(depId)) {
@@ -20383,6 +20504,7 @@ var init_schema = __esm(() => {
20383
20504
  origin: exports_external.string().describe("Catalog ref (template:<name> or mixin:<name>) this mixin was derived from. Informational only — does not affect ref resolution.").optional()
20384
20505
  }).strict().superRefine((data, ctx) => {
20385
20506
  validateUniqueTaskIds(data.tasks, ctx);
20507
+ validateRepeatedTasks(data.tasks, ctx);
20386
20508
  });
20387
20509
  });
20388
20510
 
@@ -20933,6 +21055,34 @@ var init_story_batch_processor = __esm(() => {
20933
21055
  init_errors();
20934
21056
  });
20935
21057
 
21058
+ // ../atomize-core/src/core/estimation-field-mapping.ts
21059
+ function resolveEstimationFieldMapping(defaults, overrides = {}) {
21060
+ const storySource = overrides.source ? { kind: "override", field: overrides.source } : { kind: "default", fields: defaults.storyEstimateFields };
21061
+ const taskTarget = overrides.targetFields?.length ? { kind: "override", fields: [...overrides.targetFields] } : { kind: "default", fields: defaults.taskEstimateFields };
21062
+ return {
21063
+ storySource,
21064
+ taskWorkItemType: overrides.taskType ?? defaults.taskWorkItemType,
21065
+ taskTarget,
21066
+ unitLabel: taskTarget.kind === "default" ? defaults.unitLabel : undefined
21067
+ };
21068
+ }
21069
+ function readStoryEstimate(story, source) {
21070
+ const value = source.kind === "default" ? story.estimation : story.customFields?.[source.field];
21071
+ return isStoryEstimate(value) ? value : undefined;
21072
+ }
21073
+ function numericStoryEstimate(value) {
21074
+ if (typeof value === "number")
21075
+ return Number.isFinite(value) ? value : undefined;
21076
+ if (typeof value === "string" && value.trim() !== "") {
21077
+ const parsed = Number(value);
21078
+ return Number.isFinite(parsed) ? parsed : undefined;
21079
+ }
21080
+ return;
21081
+ }
21082
+ function isStoryEstimate(value) {
21083
+ return typeof value === "number" && Number.isFinite(value) || typeof value === "string" && value.trim() !== "";
21084
+ }
21085
+
20936
21086
  // ../atomize-core/src/core/condition-evaluator.ts
20937
21087
  function extractStandardFieldRefs(condition) {
20938
21088
  const refs = [];
@@ -20966,22 +21116,29 @@ function extractCustomFieldRefs(condition) {
20966
21116
  }
20967
21117
 
20968
21118
  class ConditionEvaluator {
20969
- evaluateCondition(condition, story) {
21119
+ evaluateCondition(condition, story, storyEstimateSource) {
20970
21120
  if (!condition)
20971
21121
  return true;
20972
- return this.evaluate(condition, story);
21122
+ return this.evaluate(condition, story, storyEstimateSource);
20973
21123
  }
20974
- evaluate(condition, story) {
21124
+ evaluate(condition, story, source) {
20975
21125
  if ("all" in condition) {
20976
- return condition.all.every((c2) => this.evaluate(c2, story));
21126
+ return condition.all.every((c2) => this.evaluate(c2, story, source));
20977
21127
  }
20978
21128
  if ("any" in condition) {
20979
- return condition.any.some((c2) => this.evaluate(c2, story));
21129
+ return condition.any.some((c2) => this.evaluate(c2, story, source));
20980
21130
  }
20981
21131
  if ("customField" in condition) {
20982
21132
  const fieldValue2 = story.customFields?.[condition.customField];
20983
21133
  return this.applyOperator(fieldValue2, condition.operator, condition.value);
20984
21134
  }
21135
+ if (condition.field === "estimation") {
21136
+ const estimate = readStoryEstimate(story, source ?? { kind: "default", fields: [] });
21137
+ if (estimate !== undefined && NUMERIC_OPERATORS.has(condition.operator) && numericStoryEstimate(estimate) === undefined) {
21138
+ throw new Error(`cannot compare the Story Estimate "${estimate}" with "${condition.operator}" because it is not numeric; use equals or not-equals for categories`);
21139
+ }
21140
+ return this.applyOperator(estimate, condition.operator, condition.value);
21141
+ }
20985
21142
  const fieldValue = this.resolveField(condition.field, story);
20986
21143
  return this.applyOperator(fieldValue, condition.operator, condition.value);
20987
21144
  }
@@ -21052,8 +21209,69 @@ class ConditionEvaluator {
21052
21209
  return compare(a2, b2);
21053
21210
  }
21054
21211
  }
21212
+ var NUMERIC_OPERATORS;
21055
21213
  var init_condition_evaluator = __esm(() => {
21056
21214
  init_logger();
21215
+ NUMERIC_OPERATORS = new Set(["gt", "lt", "gte", "lte"]);
21216
+ });
21217
+
21218
+ // ../atomize-core/src/templates/schema.ts
21219
+ var init_schema2 = __esm(() => {
21220
+ init_src();
21221
+ });
21222
+
21223
+ // ../atomize-core/src/core/estimation-conversion.ts
21224
+ function convertStoryEstimate(raw, conversion) {
21225
+ if (raw === undefined) {
21226
+ return { kind: "unresolvable", reason: "Story has no estimate" };
21227
+ }
21228
+ if (conversion?.table) {
21229
+ const total = lookupTableEstimate(raw, conversion.table, conversion.multipliers);
21230
+ if (total === undefined) {
21231
+ const suffix = conversion.multipliers ? " and is not a multiple of a table key" : "";
21232
+ return { kind: "unresolvable", reason: `Story Estimate "${String(raw).trim()}" is not in the conversion table${suffix}` };
21233
+ }
21234
+ return { kind: "resolved", total: roundAwayFloatNoise(total) };
21235
+ }
21236
+ const numeric = numericStoryEstimate(raw);
21237
+ if (numeric === undefined) {
21238
+ return {
21239
+ kind: "unresolvable",
21240
+ reason: `Story Estimate "${raw}" is not numeric and the Template declares no conversion for it`
21241
+ };
21242
+ }
21243
+ const factor = conversion?.factor ?? 1;
21244
+ return { kind: "resolved", total: roundAwayFloatNoise(numeric * factor) };
21245
+ }
21246
+ function roundAwayFloatNoise(value) {
21247
+ return Math.round(value * 1e9) / 1e9;
21248
+ }
21249
+ function resolveStoryEstimate(raw, config2) {
21250
+ const converted = convertStoryEstimate(raw, config2?.conversion);
21251
+ if (converted.kind === "resolved")
21252
+ return converted;
21253
+ switch (config2?.ifParentHasNoEstimation ?? "warn") {
21254
+ case "skip":
21255
+ return { kind: "skip", reason: converted.reason };
21256
+ case "use-default": {
21257
+ if (config2?.defaultParentEstimation === undefined) {
21258
+ return {
21259
+ kind: "blank",
21260
+ reason: `${converted.reason}, and no defaultParentEstimation is set`
21261
+ };
21262
+ }
21263
+ const fallback = convertStoryEstimate(config2.defaultParentEstimation, config2.conversion);
21264
+ if (fallback.kind === "resolved") {
21265
+ return { kind: "resolved", total: fallback.total, fallbackReason: converted.reason };
21266
+ }
21267
+ return { kind: "blank", reason: `${converted.reason}, and the default Story Estimate is unusable: ${fallback.reason}` };
21268
+ }
21269
+ default:
21270
+ return { kind: "blank", reason: converted.reason };
21271
+ }
21272
+ }
21273
+ var init_estimation_conversion = __esm(() => {
21274
+ init_schema2();
21057
21275
  });
21058
21276
 
21059
21277
  // ../atomize-core/src/utils/estimation-normalizer.ts
@@ -21075,7 +21293,9 @@ var init_estimation_normalizer = __esm(() => {
21075
21293
  // ../atomize-core/src/core/estimation-distribution.ts
21076
21294
  function distributeActiveTaskPercentages(tasks, options = {}) {
21077
21295
  const totalBefore = totalEstimationPercent(tasks);
21078
- const normalized = totalBefore < 100 || totalBefore > 100 && options.forceNormalize === true ? normalizeEstimationPercentages2(tasks, {
21296
+ const mode = options.mode ?? "auto";
21297
+ const shouldNormalize = mode === "always" ? totalBefore !== 100 : mode === "never" ? false : totalBefore < 100 || totalBefore > 100 && options.forceNormalize === true;
21298
+ const normalized = shouldNormalize ? normalizeEstimationPercentages2(tasks, {
21079
21299
  skipIfAlreadyNormalized: false,
21080
21300
  enableLogging: options.enableLogging ?? true
21081
21301
  }) : false;
@@ -21100,16 +21320,32 @@ function totalEstimationPercent(tasks) {
21100
21320
  return tasks.reduce((sum, task) => sum + (task.estimationPercent ?? 0), 0);
21101
21321
  }
21102
21322
  function totalUnconditionalEstimationPercent(tasks) {
21103
- return tasks.filter((task) => !task.condition).reduce((sum, task) => sum + (task.estimationPercent ?? 0), 0);
21323
+ return tasks.filter((task) => !task.condition).reduce((sum, task) => sum + (task.estimationPercent ?? 0) * copyCount(task), 0);
21104
21324
  }
21105
21325
  function shouldOfferOverageNormalization(tasks) {
21106
21326
  const total = totalEstimationPercent(tasks);
21107
21327
  return { shouldOffer: total > 100, total };
21108
21328
  }
21109
21329
  var init_estimation_distribution = __esm(() => {
21330
+ init_schema2();
21110
21331
  init_estimation_normalizer();
21111
21332
  });
21112
21333
 
21334
+ // ../atomize-core/src/core/task-repeater.ts
21335
+ function repeatTasks(tasks) {
21336
+ return tasks.flatMap((task) => {
21337
+ const iterationCount = task.repeat ?? 1;
21338
+ return Array.from({ length: iterationCount }, (_2, index) => ({
21339
+ task,
21340
+ iteration: index + 1,
21341
+ iterationCount
21342
+ }));
21343
+ });
21344
+ }
21345
+ function withOrdinal(title, { iteration, iterationCount }) {
21346
+ return iterationCount > 1 ? `${title} (${iteration})` : title;
21347
+ }
21348
+
21113
21349
  // ../atomize-core/src/core/template-interpolator.ts
21114
21350
  function interpolateValue(template, story, onMissing) {
21115
21351
  return template.replace(/\${story\.title}/g, story.title).replace(/\${story\.id}/g, story.id).replace(/\${story\.description}/g, story.description ?? "").replace(/\{\{\s*story\.customFields\['([^']+)'\]\s*\}\}/g, (_match, referenceName) => {
@@ -21130,20 +21366,29 @@ class EstimationCalculator {
21130
21366
  constructor(conditionEvaluator = new ConditionEvaluator) {
21131
21367
  this.conditionEvaluator = conditionEvaluator;
21132
21368
  }
21133
- calculateTasksWithSkipped(story, connectUserEmail, templateTasks, estimationConfig, forceNormalize = false) {
21369
+ calculateTasksWithSkipped(story, connectUserEmail, templateTasks, estimationConfig, forceNormalize = false, fieldMapping) {
21134
21370
  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.`);
21371
+ const resolution = this.resolveParentEstimate(story, estimationConfig, fieldMapping);
21372
+ if (resolution.kind === "skip") {
21373
+ logger.warn(`Story ${story.id}: ${resolution.reason}. Skipping the Story.`);
21374
+ return {
21375
+ calculatedTasks: [],
21376
+ skippedTasks: [],
21377
+ unresolvedEstimate: { action: "skip", reason: resolution.reason }
21378
+ };
21379
+ }
21380
+ if (resolution.kind === "blank") {
21381
+ logger.warn(`Story ${story.id}: ${resolution.reason}. Task Estimates will be left blank.`);
21138
21382
  }
21139
- const calculatedTasks = [];
21140
21383
  const skippedTasks = [];
21384
+ const activeTasks = [];
21385
+ const resolvedPercents = new Map;
21141
21386
  for (const templateTask of templateTasks) {
21142
21387
  logger.debug(`Processing template task: ${templateTask.title} (Condition: ${templateTask.condition ? JSON.stringify(templateTask.condition) : "none"})`);
21143
21388
  if (templateTask.condition) {
21144
21389
  let conditionMet;
21145
21390
  try {
21146
- conditionMet = this.conditionEvaluator.evaluateCondition(templateTask.condition, story);
21391
+ conditionMet = this.conditionEvaluator.evaluateCondition(templateTask.condition, story, fieldMapping?.storySource);
21147
21392
  } catch (error62) {
21148
21393
  const reason = `Condition evaluation error: ${getErrorMessage(error62)}`;
21149
21394
  logger.error(`Skipping task "${templateTask.title}" - ${reason}`);
@@ -21158,25 +21403,52 @@ class EstimationCalculator {
21158
21403
  }
21159
21404
  logger.debug(`Task "${templateTask.title}" - condition met: ${JSON.stringify(templateTask.condition)}`);
21160
21405
  }
21161
- const resolvedPercent = this.resolveEffectivePercent(templateTask, story);
21162
- const calculatedTask = this.buildCalculatedTask(templateTask, story, connectUserEmail, 0, resolvedPercent);
21163
- calculatedTasks.push(calculatedTask);
21406
+ let resolvedPercent;
21407
+ try {
21408
+ resolvedPercent = this.resolveEffectivePercent(templateTask, story, fieldMapping);
21409
+ } catch (error62) {
21410
+ const reason = `Condition evaluation error: ${getErrorMessage(error62)}`;
21411
+ logger.error(`Skipping task "${templateTask.title}" - ${reason}`);
21412
+ skippedTasks.push({ templateTask, reason });
21413
+ continue;
21414
+ }
21415
+ activeTasks.push(templateTask);
21416
+ resolvedPercents.set(templateTask, resolvedPercent);
21417
+ }
21418
+ const calculatedTasks = repeatTasks(activeTasks).map((copy) => {
21419
+ const calculatedTask = this.buildCalculatedTask(copy.task, story, connectUserEmail, 0, resolvedPercents.get(copy.task));
21420
+ calculatedTask.title = withOrdinal(calculatedTask.title, copy);
21421
+ return calculatedTask;
21422
+ });
21423
+ if (fieldMapping) {
21424
+ for (const task of calculatedTasks) {
21425
+ task.workItemType = fieldMapping.taskWorkItemType;
21426
+ if (fieldMapping.taskTarget.kind === "override") {
21427
+ task.estimateFields = [...fieldMapping.taskTarget.fields];
21428
+ }
21429
+ }
21164
21430
  }
21165
21431
  distributeActiveTaskPercentages(calculatedTasks, {
21166
21432
  forceNormalize,
21167
- enableLogging: true
21433
+ enableLogging: true,
21434
+ mode: estimationConfig?.normalize
21168
21435
  });
21169
21436
  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}%)`);
21437
+ calculatedTask.estimation = resolution.kind === "resolved" ? this.calculateEstimation(resolution.total, calculatedTask, estimationConfig) : calculatedTask.estimationFixed;
21438
+ logger.debug(`Calculated task: ${calculatedTask.title} = ${calculatedTask.estimation ?? "unestimated"} (${calculatedTask.estimationPercent}%)`);
21172
21439
  }
21173
21440
  logger.info(`EstimationCalculator: Calculated ${calculatedTasks.length} tasks, skipped ${skippedTasks.length} tasks for story ${story.id}`);
21174
- return { calculatedTasks, skippedTasks };
21441
+ return {
21442
+ calculatedTasks,
21443
+ skippedTasks,
21444
+ ...resolution.kind === "blank" ? { unresolvedEstimate: { action: "blank", reason: resolution.reason } } : {},
21445
+ ...resolution.kind === "resolved" && resolution.fallbackReason ? { defaultEstimateReason: resolution.fallbackReason } : {}
21446
+ };
21175
21447
  }
21176
- resolveEffectivePercent(task, story) {
21448
+ resolveEffectivePercent(task, story, fieldMapping) {
21177
21449
  if (task.estimationPercentCondition?.length) {
21178
21450
  for (const rule of task.estimationPercentCondition) {
21179
- if (this.conditionEvaluator.evaluateCondition(rule.condition, story)) {
21451
+ if (this.conditionEvaluator.evaluateCondition(rule.condition, story, fieldMapping?.storySource)) {
21180
21452
  logger.debug(`Task "${task.title}": conditional percent → ${rule.percent}%`);
21181
21453
  return rule.percent;
21182
21454
  }
@@ -21193,7 +21465,6 @@ class EstimationCalculator {
21193
21465
  assignTo: this.resolveAssignment(templateTask.assignTo, story, connectUserEmail),
21194
21466
  priority: templateTask.priority,
21195
21467
  activity: templateTask.activity,
21196
- completedWork: 0,
21197
21468
  iteration: story.iteration,
21198
21469
  areaPath: story.areaPath,
21199
21470
  customFields: this.interpolateCustomFields(templateTask.customFields, story),
@@ -21202,6 +21473,10 @@ class EstimationCalculator {
21202
21473
  estimationFixed: templateTask.estimationFixed
21203
21474
  };
21204
21475
  }
21476
+ resolveParentEstimate(story, config2, fieldMapping) {
21477
+ const raw = readStoryEstimate(story, fieldMapping?.storySource ?? { kind: "default", fields: [] });
21478
+ return resolveStoryEstimate(raw, config2);
21479
+ }
21205
21480
  calculateEstimation(parentEstimation, task, config2) {
21206
21481
  if (task.estimationFixed !== undefined) {
21207
21482
  return task.estimationFixed;
@@ -21209,7 +21484,7 @@ class EstimationCalculator {
21209
21484
  if (task.estimationPercent !== undefined) {
21210
21485
  const rawEstimation = parentEstimation * task.estimationPercent / 100;
21211
21486
  const rounded = this.roundEstimation(rawEstimation, config2?.rounding || "none");
21212
- const minimum = config2?.minimumTaskPoints || 0;
21487
+ const minimum = config2?.minimumTaskEstimate ?? config2?.minimumTaskPoints ?? 0;
21213
21488
  return Math.max(rounded, minimum);
21214
21489
  }
21215
21490
  if (task.estimationFormula) {
@@ -21252,8 +21527,9 @@ class EstimationCalculator {
21252
21527
  calculateTotalEstimation(tasks) {
21253
21528
  return tasks.reduce((sum, task) => sum + (task.estimation || 0), 0);
21254
21529
  }
21255
- getEstimationSummary(story, tasks) {
21256
- const storyEstimation = story.estimation || 0;
21530
+ getEstimationSummary(story, tasks, config2, fieldMapping) {
21531
+ const resolution = this.resolveParentEstimate(story, config2, fieldMapping);
21532
+ const storyEstimation = resolution.kind === "resolved" ? resolution.total : 0;
21257
21533
  const totalTaskEstimation = this.calculateTotalEstimation(tasks);
21258
21534
  const difference = storyEstimation - totalTaskEstimation;
21259
21535
  const percentageUsed = storyEstimation > 0 ? totalTaskEstimation / storyEstimation * 100 : 0;
@@ -21264,13 +21540,16 @@ class EstimationCalculator {
21264
21540
  percentageUsed
21265
21541
  };
21266
21542
  }
21267
- validateEstimation(story, tasks) {
21543
+ validateEstimation(story, tasks, config2, fieldMapping) {
21268
21544
  const warnings = [];
21269
- const summary = this.getEstimationSummary(story, tasks);
21545
+ if (this.resolveParentEstimate(story, config2, fieldMapping).kind !== "resolved") {
21546
+ return { valid: true, warnings };
21547
+ }
21548
+ const summary = this.getEstimationSummary(story, tasks, config2, fieldMapping);
21270
21549
  if (Math.abs(summary.difference) > 0.5) {
21271
21550
  warnings.push(`Total task estimation (${summary.totalTaskEstimation}) differs from story estimation (${summary.storyEstimation}) by ${summary.difference}`);
21272
21551
  }
21273
- const zeroEstimations = tasks.filter((t2) => (t2.estimation || 0) === 0);
21552
+ const zeroEstimations = tasks.filter((t2) => t2.estimation === 0);
21274
21553
  if (zeroEstimations.length > 0) {
21275
21554
  warnings.push(`${zeroEstimations.length} task(s) have zero estimation: ${zeroEstimations.map((t2) => t2.title).join(", ")}`);
21276
21555
  }
@@ -21285,6 +21564,7 @@ var init_estimation_calculator = __esm(() => {
21285
21564
  init_logger();
21286
21565
  init_errors();
21287
21566
  init_condition_evaluator();
21567
+ init_estimation_conversion();
21288
21568
  init_estimation_distribution();
21289
21569
  });
21290
21570
 
@@ -21361,27 +21641,54 @@ var init_task_materializer = __esm(() => {
21361
21641
  class StoryProcessor {
21362
21642
  estimationCalculator;
21363
21643
  taskMaterializer;
21644
+ estimationDefaults;
21364
21645
  constructor(platform, estimationCalculator = new EstimationCalculator, taskMaterializer = new TaskMaterializer(platform)) {
21365
21646
  this.estimationCalculator = estimationCalculator;
21366
21647
  this.taskMaterializer = taskMaterializer;
21648
+ this.estimationDefaults = platform.getEstimationDefaults?.();
21367
21649
  }
21368
21650
  async process(story, orderedTasks, template, connectUserEmail, options, warnings) {
21369
21651
  logger.info(`Processing: ${story.id} - ${story.title}`);
21370
- const { calculatedTasks, skippedTasks } = this.estimationCalculator.calculateTasksWithSkipped(story, connectUserEmail, orderedTasks, template.estimation, options.forceNormalize);
21652
+ const fieldMapping = this.estimationDefaults ? resolveEstimationFieldMapping(this.estimationDefaults, {
21653
+ source: template.estimation?.source,
21654
+ taskType: template.taskType,
21655
+ targetFields: template.estimation?.targetFields
21656
+ }) : undefined;
21657
+ const { calculatedTasks, skippedTasks, unresolvedEstimate, defaultEstimateReason } = this.estimationCalculator.calculateTasksWithSkipped(story, connectUserEmail, orderedTasks, template.estimation, options.forceNormalize, fieldMapping);
21371
21658
  logger.info(`Generated ${calculatedTasks.length} tasks, skipped ${skippedTasks.length} conditional tasks`);
21659
+ if (unresolvedEstimate?.action === "skip") {
21660
+ const skipReason = `${unresolvedEstimate.reason}; Story skipped (ifParentHasNoEstimation: skip)`;
21661
+ warnings.push(`${story.id}: ${skipReason}`);
21662
+ return {
21663
+ story,
21664
+ tasksCalculated: [],
21665
+ tasksCreated: [],
21666
+ tasksSkipped: [],
21667
+ success: true,
21668
+ ...fieldMapping?.unitLabel ? { estimateUnit: fieldMapping.unitLabel } : {},
21669
+ ...withStoryEstimate(readStoryEstimate(story, fieldMapping?.storySource ?? { kind: "default", fields: [] })),
21670
+ skipReason
21671
+ };
21672
+ }
21673
+ if (unresolvedEstimate?.action === "blank") {
21674
+ warnings.push(`${story.id}: ${unresolvedEstimate.reason}; Task Estimates left blank`);
21675
+ }
21676
+ if (defaultEstimateReason) {
21677
+ warnings.push(`${story.id}: ${defaultEstimateReason}; used defaultParentEstimation`);
21678
+ }
21372
21679
  if (skippedTasks.length > 0) {
21373
21680
  skippedTasks.forEach((skipped) => {
21374
21681
  logger.debug(` Skipped: "${skipped.templateTask.title}" - ${skipped.reason}`);
21375
21682
  });
21376
21683
  }
21377
- const validation = this.estimationCalculator.validateEstimation(story, calculatedTasks);
21684
+ const validation = this.estimationCalculator.validateEstimation(story, calculatedTasks, template.estimation, fieldMapping);
21378
21685
  if (!validation.valid) {
21379
21686
  validation.warnings.forEach((warning) => {
21380
21687
  logger.warn(`${warning}`);
21381
21688
  warnings.push(`${story.id}: ${warning}`);
21382
21689
  });
21383
21690
  }
21384
- const estimationSummary = this.estimationCalculator.getEstimationSummary(story, calculatedTasks);
21691
+ const estimationSummary = this.estimationCalculator.getEstimationSummary(story, calculatedTasks, template.estimation, fieldMapping);
21385
21692
  logger.debug("Estimation summary:", estimationSummary);
21386
21693
  let tasksCreated = [];
21387
21694
  if (options.dryRun) {
@@ -21397,10 +21704,15 @@ class StoryProcessor {
21397
21704
  tasksCreated,
21398
21705
  tasksSkipped: skippedTasks,
21399
21706
  success: true,
21707
+ ...fieldMapping?.unitLabel ? { estimateUnit: fieldMapping.unitLabel } : {},
21708
+ ...withStoryEstimate(readStoryEstimate(story, fieldMapping?.storySource ?? { kind: "default", fields: [] })),
21400
21709
  estimationSummary
21401
21710
  };
21402
21711
  }
21403
21712
  }
21713
+ function withStoryEstimate(estimate) {
21714
+ return estimate === undefined ? {} : { storyEstimate: estimate };
21715
+ }
21404
21716
  var init_story_processor = __esm(() => {
21405
21717
  init_logger();
21406
21718
  init_estimation_calculator();
@@ -79871,29 +80183,33 @@ class FieldSchemaService {
79871
80183
  const allowedValues = witField.allowedValues && witField.allowedValues.length > 0 ? witField.allowedValues : undefined;
79872
80184
  const isPicklist = !!(allowedValues ?? meta3?.isPicklist);
79873
80185
  const isIdentity = meta3?.isIdentity === true || fieldType === import_WorkItemTrackingInterfaces.FieldType.Identity;
80186
+ const type = isIdentity ? "identity" : this.mapFieldType(fieldType);
79874
80187
  return {
79875
80188
  referenceName,
79876
80189
  name: witField.name ?? meta3?.name ?? referenceName,
79877
- type: isIdentity ? "identity" : this.mapFieldType(fieldType),
80190
+ type,
79878
80191
  isCustom: referenceName.startsWith("Custom."),
79879
80192
  isReadOnly: meta3?.readOnly ?? false,
79880
80193
  isMultiline: !isPicklist && (fieldType === import_WorkItemTrackingInterfaces.FieldType.Html || fieldType === import_WorkItemTrackingInterfaces.FieldType.PlainText),
79881
80194
  isPicklist,
79882
- allowedValues
80195
+ allowedValues,
80196
+ allowsCustomValues: isPicklist ? meta3?.isPicklistSuggested === true : type === "string"
79883
80197
  };
79884
80198
  }
79885
80199
  mapWorkItemField(f) {
79886
80200
  const referenceName = f.referenceName ?? "";
79887
80201
  const isIdentity = f.isIdentity === true || f.type === import_WorkItemTrackingInterfaces.FieldType.Identity;
80202
+ const type = isIdentity ? "identity" : this.mapFieldType(f.type);
79888
80203
  return {
79889
80204
  referenceName,
79890
80205
  name: f.name ?? referenceName,
79891
- type: isIdentity ? "identity" : this.mapFieldType(f.type),
80206
+ type,
79892
80207
  isCustom: referenceName.startsWith("Custom."),
79893
80208
  isReadOnly: f.readOnly ?? false,
79894
80209
  isMultiline: !f.isPicklist && (f.type === import_WorkItemTrackingInterfaces.FieldType.Html || f.type === import_WorkItemTrackingInterfaces.FieldType.PlainText),
79895
80210
  isPicklist: f.isPicklist ?? false,
79896
- allowedValues: undefined
80211
+ allowedValues: undefined,
80212
+ allowsCustomValues: f.isPicklist ? f.isPicklistSuggested === true : type === "string"
79897
80213
  };
79898
80214
  }
79899
80215
  mapFieldType(fieldType) {
@@ -79920,6 +80236,33 @@ var init_azure_devops_field_schema_service = __esm(() => {
79920
80236
  import_WorkItemTrackingInterfaces = __toESM(require_WorkItemTrackingInterfaces(), 1);
79921
80237
  });
79922
80238
 
80239
+ // ../atomize-core/src/platforms/adapters/azure-devops/estimation-defaults.ts
80240
+ var AZURE_DEVOPS_ESTIMATION_DEFAULTS;
80241
+ var init_estimation_defaults = __esm(() => {
80242
+ AZURE_DEVOPS_ESTIMATION_DEFAULTS = {
80243
+ storyEstimateFields: [
80244
+ "Microsoft.VSTS.Scheduling.StoryPoints",
80245
+ "Microsoft.VSTS.Scheduling.Effort",
80246
+ "Microsoft.VSTS.Scheduling.Size",
80247
+ "Microsoft.VSTS.Scheduling.OriginalEstimate"
80248
+ ],
80249
+ taskWorkItemType: "Task",
80250
+ taskEstimateFields: [
80251
+ "Microsoft.VSTS.Scheduling.RemainingWork",
80252
+ "Microsoft.VSTS.Scheduling.OriginalEstimate"
80253
+ ],
80254
+ unitLabel: "hours",
80255
+ nonEstimateFields: [
80256
+ "Microsoft.VSTS.Common.*",
80257
+ "Microsoft.VSTS.Build.*",
80258
+ "Microsoft.VSTS.CMMI.*",
80259
+ "Microsoft.VSTS.CodeReview.*",
80260
+ "Microsoft.VSTS.Feedback.*",
80261
+ "Microsoft.VSTS.TCM.*"
80262
+ ]
80263
+ };
80264
+ });
80265
+
79923
80266
  // ../atomize-core/src/platforms/adapters/azure-devops/task-patch-builder.ts
79924
80267
  function resolveDateMacro(value) {
79925
80268
  if (typeof value !== "string")
@@ -79951,7 +80294,7 @@ function resolveDateMacro(value) {
79951
80294
  base.setDate(base.getDate() + offsetDays);
79952
80295
  return base.toISOString().slice(0, 10);
79953
80296
  }
79954
- function buildCreateTaskPatch(organizationUrl, parentId, task) {
80297
+ function buildCreateTaskPatch(organizationUrl, parentId, task, estimateWrite) {
79955
80298
  return [
79956
80299
  {
79957
80300
  op: "add",
@@ -79965,25 +80308,12 @@ function buildCreateTaskPatch(organizationUrl, parentId, task) {
79965
80308
  value: task.description
79966
80309
  }
79967
80310
  ] : [],
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
- ] : [],
80311
+ ...task.estimation !== undefined ? estimateWrite.fields.map((field) => ({
80312
+ op: "add",
80313
+ path: `/fields/${field}`,
80314
+ value: task.estimation
80315
+ })) : [],
80316
+ ...estimateWrite.initializeCompletedWork ? [{ op: "add", path: "/fields/Microsoft.VSTS.Scheduling.CompletedWork", value: 0 }] : [],
79987
80317
  ...task.iteration ? [
79988
80318
  {
79989
80319
  op: "add",
@@ -80065,6 +80395,14 @@ var init_task_patch_builder = __esm(() => {
80065
80395
  });
80066
80396
 
80067
80397
  // ../atomize-core/src/platforms/adapters/azure-devops/work-item-mapper.ts
80398
+ function firstEstimate(fields) {
80399
+ for (const field of AZURE_DEVOPS_ESTIMATION_DEFAULTS.storyEstimateFields) {
80400
+ const value = fields[field];
80401
+ if ((typeof value === "number" || typeof value === "string") && value)
80402
+ return value;
80403
+ }
80404
+ return;
80405
+ }
80068
80406
  function convertWorkItem(azureItem) {
80069
80407
  const fields = azureItem.fields || {};
80070
80408
  const predecessorIds = [];
@@ -80091,7 +80429,7 @@ function convertWorkItem(azureItem) {
80091
80429
  type: fields["System.WorkItemType"],
80092
80430
  state: fields["System.State"] || "",
80093
80431
  assignedTo: fields["System.AssignedTo"]?.uniqueName || fields["System.AssignedTo"]?.displayName,
80094
- estimation: fields["Microsoft.VSTS.Scheduling.StoryPoints"] || fields["Microsoft.VSTS.Scheduling.OriginalEstimate"],
80432
+ estimation: firstEstimate(fields),
80095
80433
  tags: fields["System.Tags"] ? fields["System.Tags"].split("; ") : [],
80096
80434
  description: fields["System.Description"],
80097
80435
  areaPath: fields["System.AreaPath"],
@@ -80115,10 +80453,8 @@ function hasChildRelations(workItem) {
80115
80453
  }
80116
80454
  return workItem.relations.some((rel) => rel.rel === "System.LinkTypes.Hierarchy-Forward");
80117
80455
  }
80118
-
80119
- // ../atomize-core/src/templates/schema.ts
80120
- var init_schema2 = __esm(() => {
80121
- init_src();
80456
+ var init_work_item_mapper = __esm(() => {
80457
+ init_estimation_defaults();
80122
80458
  });
80123
80459
 
80124
80460
  // ../atomize-core/src/platforms/adapters/azure-devops/work-item-query.ts
@@ -80383,6 +80719,7 @@ var import_WorkItemTrackingInterfaces2;
80383
80719
  var init_work_item_retrieval = __esm(() => {
80384
80720
  init_logger();
80385
80721
  init_errors();
80722
+ init_work_item_mapper();
80386
80723
  init_work_item_query();
80387
80724
  import_WorkItemTrackingInterfaces2 = __toESM(require_WorkItemTrackingInterfaces(), 1);
80388
80725
  });
@@ -80490,11 +80827,13 @@ class AzureDevOpsAdapter {
80490
80827
  if (Number.isNaN(numericParentId)) {
80491
80828
  throw new Error(`Invalid parent ID: ${parentId}`);
80492
80829
  }
80493
- const patchDocument = buildCreateTaskPatch(this.config.organizationUrl, numericParentId, task);
80830
+ const defaults = this.getEstimationDefaults();
80831
+ const estimateWrite = task.estimateFields?.length ? { fields: task.estimateFields } : { fields: defaults.taskEstimateFields, initializeCompletedWork: true };
80832
+ const patchDocument = buildCreateTaskPatch(this.config.organizationUrl, numericParentId, task, estimateWrite);
80494
80833
  if (!this.witApi) {
80495
80834
  throw new UnknownError("Work Item Tracking API not initialized");
80496
80835
  }
80497
- const createdItem = await this.witApi.createWorkItem(undefined, patchDocument, this.config.project, "Task");
80836
+ const createdItem = await this.witApi.createWorkItem(undefined, patchDocument, this.config.project, task.workItemType ?? defaults.taskWorkItemType);
80498
80837
  logger.info(`AzureDevOps: Created task ${createdItem.id}: ${task.title}`);
80499
80838
  return convertWorkItem(createdItem);
80500
80839
  } catch (error62) {
@@ -80531,6 +80870,9 @@ class AzureDevOpsAdapter {
80531
80870
  logger.info(`AzureDevOps: Created ${createdTasks.length} of ${tasks.length} tasks`);
80532
80871
  return createdTasks;
80533
80872
  }
80873
+ getEstimationDefaults() {
80874
+ return AZURE_DEVOPS_ESTIMATION_DEFAULTS;
80875
+ }
80534
80876
  getPlatformMetadata() {
80535
80877
  return {
80536
80878
  name: "Azure DevOps",
@@ -80743,7 +81085,9 @@ var init_azure_devops_adapter = __esm(() => {
80743
81085
  init_logger();
80744
81086
  init_errors();
80745
81087
  init_azure_devops_field_schema_service();
81088
+ init_estimation_defaults();
80746
81089
  init_task_patch_builder();
81090
+ init_work_item_mapper();
80747
81091
  init_work_item_query();
80748
81092
  init_work_item_retrieval();
80749
81093
  azdev = __toESM(require_WebApi(), 1);
@@ -80995,7 +81339,7 @@ class MockPlatformAdapter {
80995
81339
  const createdTask = {
80996
81340
  id: `TASK-${this.taskIdCounter++}`,
80997
81341
  title: task.title,
80998
- type: "Task",
81342
+ type: task.workItemType ?? MOCK_ESTIMATION_DEFAULTS.taskWorkItemType,
80999
81343
  state: "New",
81000
81344
  assignedTo: task.assignTo,
81001
81345
  estimation: task.estimation,
@@ -81063,6 +81407,9 @@ class MockPlatformAdapter {
81063
81407
  logger.info(`MockPlatform: Deleted work item ${id}`);
81064
81408
  return true;
81065
81409
  }
81410
+ getEstimationDefaults() {
81411
+ return MOCK_ESTIMATION_DEFAULTS;
81412
+ }
81066
81413
  getPlatformMetadata() {
81067
81414
  return {
81068
81415
  name: "Mock Platform",
@@ -81109,9 +81456,16 @@ class MockPlatformAdapter {
81109
81456
  return new Promise((resolve) => setTimeout(resolve, ms));
81110
81457
  }
81111
81458
  }
81459
+ var MOCK_ESTIMATION_DEFAULTS;
81112
81460
  var init_mock_adapter = __esm(() => {
81113
81461
  init_logger();
81114
81462
  init_mock_data();
81463
+ MOCK_ESTIMATION_DEFAULTS = {
81464
+ storyEstimateFields: ["estimation"],
81465
+ taskWorkItemType: "Task",
81466
+ taskEstimateFields: ["estimation"],
81467
+ unitLabel: "hours"
81468
+ };
81115
81469
  });
81116
81470
 
81117
81471
  // ../atomize-core/src/platforms/platform-factory.ts
@@ -88764,6 +89118,7 @@ function mergeTemplates(base, child) {
88764
89118
  lastModified: child.lastModified ?? base.lastModified,
88765
89119
  filter: child.filter ?? base.filter,
88766
89120
  tasks: mergeTasks(base.tasks, child.tasks ?? []),
89121
+ taskType: child.taskType ?? base.taskType,
88767
89122
  estimation: child.estimation ?? base.estimation,
88768
89123
  validation: child.validation ?? base.validation,
88769
89124
  metadata: child.metadata ?? base.metadata
@@ -89195,12 +89550,13 @@ class TemplateValidator {
89195
89550
  collectWarnings(template) {
89196
89551
  const warnings = [];
89197
89552
  const v2 = template.validation;
89198
- const total = template.tasks.reduce((sum, t2) => sum + (t2.estimationPercent ?? 0), 0);
89553
+ const total = template.tasks.reduce((sum, t2) => sum + (t2.estimationPercent ?? 0) * copyCount(t2), 0);
89199
89554
  const hasStrictRule = v2?.totalEstimationMustBe !== undefined || v2?.totalEstimationRange !== undefined;
89200
- if (!hasStrictRule && total > 100) {
89555
+ const normalize = template.estimation?.normalize ?? "auto";
89556
+ if (!hasStrictRule && total > 100 && normalize !== "always") {
89201
89557
  warnings.push({
89202
89558
  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.`,
89559
+ 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
89560
  nonBlocking: true
89205
89561
  });
89206
89562
  }
@@ -89215,8 +89571,31 @@ class TemplateValidator {
89215
89571
  this.validateTaskConditions(template, warnings);
89216
89572
  this.validateTaskDependencies(template, warnings);
89217
89573
  this.validateSavedQueryConflict(template, warnings);
89574
+ this.validateDeprecatedMinimumTaskPoints(template, warnings);
89218
89575
  return warnings;
89219
89576
  }
89577
+ validateDeprecatedMinimumTaskPoints(template, warnings) {
89578
+ const estimation = template.estimation;
89579
+ if (estimation?.minimumTaskPoints === undefined)
89580
+ return;
89581
+ if (estimation.minimumTaskEstimate === undefined) {
89582
+ warnings.push({
89583
+ path: "estimation.minimumTaskPoints",
89584
+ message: "minimumTaskPoints is deprecated; the minimum applies in the Task's unit, which may not be points.",
89585
+ suggestion: `Rename it to minimumTaskEstimate: ${estimation.minimumTaskPoints}.`,
89586
+ code: FixableWarningCode.DEPRECATED_MINIMUM_TASK_POINTS,
89587
+ nonBlocking: true
89588
+ });
89589
+ return;
89590
+ }
89591
+ warnings.push({
89592
+ path: "estimation.minimumTaskPoints",
89593
+ message: `Both minimumTaskEstimate and the deprecated minimumTaskPoints are set; minimumTaskEstimate (${estimation.minimumTaskEstimate}) is used and minimumTaskPoints (${estimation.minimumTaskPoints}) is ignored.`,
89594
+ suggestion: "Remove minimumTaskPoints.",
89595
+ code: FixableWarningCode.DUPLICATE_MINIMUM_TASK_ESTIMATE,
89596
+ nonBlocking: true
89597
+ });
89598
+ }
89220
89599
  collectMixinWarnings(mixin) {
89221
89600
  const warnings = [];
89222
89601
  this.validateTaskDependencies(mixin, warnings);
@@ -89266,13 +89645,13 @@ class TemplateValidator {
89266
89645
  const f = template.filter;
89267
89646
  if (!f.savedQuery)
89268
89647
  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;
89648
+ 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
89649
  if (hasStructuredFields) {
89271
89650
  warnings.push({
89272
89651
  path: "filter.savedQuery",
89273
89652
  message: "savedQuery and structured filter fields are both set. Structured filter fields will be ignored — the saved query controls which items are returned.",
89274
89653
  code: FixableWarningCode.SAVED_QUERY_WITH_STRUCTURED_FILTER,
89275
- suggestion: "Remove workItemTypes, states, tags, etc. from the filter when using savedQuery."
89654
+ suggestion: "Remove states, tags, area paths, etc. from the filter when using savedQuery. Keep workItemTypes: it declares which types the query returns."
89276
89655
  });
89277
89656
  }
89278
89657
  }
@@ -89297,22 +89676,32 @@ class TemplateValidator {
89297
89676
  return this.handleZodError(err);
89298
89677
  }
89299
89678
  handleDomainError(err, code) {
89679
+ const params = err.code === "custom" ? err.params : undefined;
89680
+ const repeatHint = params?.hasRepeats ? " Each copy of a repeated task counts toward this total." : "";
89300
89681
  switch (code) {
89301
89682
  case "INVALID_TOTAL_ESTIMATION":
89302
- return this.handleNumericError(err.message, /is (\d+)%, but must be (\d+)%/, (current, required2) => {
89683
+ return this.withHint(repeatHint, this.handleNumericError(err.message, /is (\d+)%, but must be (\d+)%/, (current, required2) => {
89303
89684
  const diff = required2 - current;
89304
89685
  return diff > 0 ? `Add ${diff}% to existing tasks or create a new task with ${diff}% estimation.` : `Reduce task estimations by ${Math.abs(diff)}% to reach ${required2}%.`;
89305
- });
89686
+ }));
89306
89687
  case "INVALID_ESTIMATION_RANGE":
89307
- return this.handleNumericError(err.message, /is (\d+)%, but must be between (\d+)% and (\d+)%/, (current, min, max) => {
89688
+ return this.withHint(repeatHint, this.handleNumericError(err.message, /is (\d+)%, but must be between (\d+)% and (\d+)%/, (current, min, max) => {
89308
89689
  if (current < min)
89309
89690
  return `Increase task estimations by ${min - current}% to meet the minimum of ${min}%.`;
89310
89691
  return `Reduce task estimations by ${current - max}% to stay within the maximum of ${max}%.`;
89311
- });
89312
- case "TOO_FEW_TASKS":
89313
- return this.handleNumericError(err.message, /has (\d+) tasks?, but minimum is (\d+)/, (current, required2) => `Add ${required2 - current} more task(s) to meet the minimum requirement of ${required2} tasks.`);
89314
- case "TOO_MANY_TASKS":
89315
- return this.handleNumericError(err.message, /has (\d+) tasks?, but maximum is (\d+)/, (current, max) => `Remove ${current - max} task(s) or increase the maxTasks limit to ${current}.`);
89692
+ }));
89693
+ case "TOO_FEW_TASKS": {
89694
+ if (typeof params?.count !== "number" || typeof params.limit !== "number")
89695
+ return;
89696
+ const { count, limit } = params;
89697
+ return params.hasRepeats ? `Add ${limit - count} more task(s) or raise a repeat count to meet the minimum requirement of ${limit} tasks.` : `Add ${limit - count} more task(s) to meet the minimum requirement of ${limit} tasks.`;
89698
+ }
89699
+ case "TOO_MANY_TASKS": {
89700
+ if (typeof params?.count !== "number" || typeof params.limit !== "number")
89701
+ return;
89702
+ const { count, limit } = params;
89703
+ return params.hasRepeats ? `Remove ${count - limit} task(s), lower a repeat count, or increase the maxTasks limit to ${count}.` : `Remove ${count - limit} task(s) or increase the maxTasks limit to ${count}.`;
89704
+ }
89316
89705
  case "INVALID_DEPENDENCY": {
89317
89706
  const match = err.message.match(/non-existent task ID: "([^"]+)"/);
89318
89707
  return match ? `Either add a task with id: "${match[1]}" or update the dependsOn field to reference an existing task ID.` : undefined;
@@ -89325,6 +89714,14 @@ class TemplateValidator {
89325
89714
  }
89326
89715
  return "Remove one of the dependencies in the cycle to break the circular reference.";
89327
89716
  }
89717
+ case "REPEAT_EXCEEDS_MAX":
89718
+ return "Lower the repeat count, or raise validation.maxRepeat if the template genuinely needs more copies.";
89719
+ case "REPEAT_WITH_DEPENDENCY":
89720
+ return "Remove dependsOn from the repeated task, or remove repeat and define each copy as its own task.";
89721
+ case "DEPENDS_ON_REPEATED_TASK": {
89722
+ const match = err.message.match(/depends on "([^"]+)"/);
89723
+ return match ? `Remove "${match[1]}" from dependsOn, or remove repeat from task "${match[1]}".` : "Remove the dependency on the repeated task, or remove its repeat.";
89724
+ }
89328
89725
  case "MISSING_REQUIRED_TASK": {
89329
89726
  const match = err.message.match(/title "([^"]+)"/);
89330
89727
  return match ? `Add a task with title "${match[1]}" to satisfy the required tasks constraint.` : "Add the missing required task to the template.";
@@ -89333,6 +89730,9 @@ class TemplateValidator {
89333
89730
  return;
89334
89731
  }
89335
89732
  }
89733
+ withHint(hint, suggestion) {
89734
+ return suggestion && `${suggestion}${hint}`;
89735
+ }
89336
89736
  handleNumericError(message, regex, formatter) {
89337
89737
  const values = this.extractValues(message, regex);
89338
89738
  if (!values || values.length === 0)
@@ -89417,15 +89817,18 @@ var init_validator = __esm(() => {
89417
89817
  FixableWarningCode = {
89418
89818
  MISSING_TASK_ID: "MISSING_TASK_ID",
89419
89819
  SAVED_QUERY_WITH_STRUCTURED_FILTER: "SAVED_QUERY_WITH_STRUCTURED_FILTER",
89420
- SINGLE_LINE_FIELD_WITH_NEWLINES: "SINGLE_LINE_FIELD_WITH_NEWLINES"
89820
+ SINGLE_LINE_FIELD_WITH_NEWLINES: "SINGLE_LINE_FIELD_WITH_NEWLINES",
89821
+ DEPRECATED_MINIMUM_TASK_POINTS: "DEPRECATED_MINIMUM_TASK_POINTS",
89822
+ DUPLICATE_MINIMUM_TASK_ESTIMATE: "DUPLICATE_MINIMUM_TASK_ESTIMATE"
89421
89823
  };
89422
89824
  });
89423
89825
 
89424
89826
  // ../atomize-core/src/templates/custom-field-verifier.ts
89425
- async function verifyTemplateCustomFields(template, getSchemas) {
89827
+ async function verifyTemplateCustomFields(template, getSchemas, taskWorkItemType) {
89426
89828
  const errors4 = [];
89427
89829
  const warnings = [];
89428
- const taskSchemas = await getSchemas("Task");
89830
+ const taskSchemas = await getSchemas(taskWorkItemType);
89831
+ const scope = taskWorkItemType ? `work item type "${taskWorkItemType}"` : "this project";
89429
89832
  const schemaByRef = new Map(taskSchemas.map((field) => [field.referenceName, field]));
89430
89833
  for (let i2 = 0;i2 < template.tasks.length; i2++) {
89431
89834
  const task = template.tasks[i2];
@@ -89437,7 +89840,7 @@ async function verifyTemplateCustomFields(template, getSchemas) {
89437
89840
  if (!schema2) {
89438
89841
  errors4.push({
89439
89842
  path,
89440
- message: `Field "${refName}" not found for work item type "Task".`,
89843
+ message: `Field "${refName}" not found for ${scope}.`,
89441
89844
  code: "CUSTOM_FIELD_NOT_FOUND"
89442
89845
  });
89443
89846
  continue;
@@ -89589,16 +89992,166 @@ var init_custom_field_verifier = __esm(() => {
89589
89992
  init_validator();
89590
89993
  });
89591
89994
 
89995
+ // ../atomize-core/src/templates/estimation-verifier.ts
89996
+ function hasEstimationMappingOverrides(template) {
89997
+ return !!(template.taskType || template.estimation?.source || template.estimation?.targetFields?.length);
89998
+ }
89999
+ async function verifyEstimationMapping(template, platform) {
90000
+ const errors4 = [];
90001
+ const warnings = [];
90002
+ if (!hasEstimationMappingOverrides(template))
90003
+ return { errors: errors4, warnings };
90004
+ const taskType = template.taskType ?? platform.getEstimationDefaults?.().taskWorkItemType;
90005
+ const nonEstimateFields = platform.getEstimationDefaults?.().nonEstimateFields ?? [];
90006
+ const taskTypeExists = await verifyTaskType(template, platform, errors4);
90007
+ const targetFields = template.estimation?.targetFields ?? [];
90008
+ if (targetFields.length > 0 && taskType && taskTypeExists) {
90009
+ const taskFields = byReference(await platform.getFieldSchemas(taskType));
90010
+ targetFields.forEach((ref, i2) => {
90011
+ const path = `estimation.targetFields[${i2}]`;
90012
+ const field = taskFields.get(ref);
90013
+ if (!field) {
90014
+ errors4.push({ path, message: `Field "${ref}" not found for work item type "${taskType}".`, code: "ESTIMATION_TARGET_FIELD_NOT_FOUND" });
90015
+ } else if (!NUMERIC_TYPES.has(field.type)) {
90016
+ errors4.push({
90017
+ path,
90018
+ message: `Field "${ref}" is a ${field.type} field; Task Estimates can only be written to numeric fields.`,
90019
+ code: "ESTIMATION_TARGET_FIELD_NOT_NUMERIC"
90020
+ });
90021
+ } else if (field.isPicklist) {
90022
+ errors4.push({
90023
+ path,
90024
+ message: `Field "${ref}" only accepts a fixed list of values, so calculated Task Estimates can't be written to it.`,
90025
+ code: "ESTIMATION_TARGET_FIELD_PICKLIST"
90026
+ });
90027
+ } else if (matchesFieldPattern(ref, nonEstimateFields)) {
90028
+ warnings.push({
90029
+ path,
90030
+ message: `Field "${ref}" holds a ranking or value, not effort; writing Task Estimates to it will overwrite that.`,
90031
+ suggestion: "Choose an effort field such as the platform's default estimate fields or a custom effort field."
90032
+ });
90033
+ }
90034
+ });
90035
+ }
90036
+ const source = template.estimation?.source;
90037
+ if (source) {
90038
+ const sourceField = await verifySourceField(template, source, platform, errors4);
90039
+ if (sourceField)
90040
+ checkSourceValues(template, sourceField, warnings);
90041
+ }
90042
+ return { errors: errors4, warnings };
90043
+ }
90044
+ async function verifyTaskType(template, platform, errors4) {
90045
+ if (!template.taskType || !platform.getWorkItemTypes)
90046
+ return true;
90047
+ const types = await platform.getWorkItemTypes();
90048
+ if (types.includes(template.taskType))
90049
+ return true;
90050
+ errors4.push({
90051
+ path: "taskType",
90052
+ message: `Work item type "${template.taskType}" does not exist in this project. Available types: ${types.join(", ")}.`,
90053
+ code: "ESTIMATION_TASK_TYPE_NOT_FOUND"
90054
+ });
90055
+ return false;
90056
+ }
90057
+ async function verifySourceField(template, source, platform, errors4) {
90058
+ const storyTypes = template.filter.workItemTypes?.length ? template.filter.workItemTypes : [undefined];
90059
+ let found;
90060
+ for (const storyType of storyTypes) {
90061
+ const field = byReference(await platform.getFieldSchemas(storyType)).get(source);
90062
+ if (field) {
90063
+ found ??= field;
90064
+ continue;
90065
+ }
90066
+ errors4.push({
90067
+ path: "estimation.source",
90068
+ message: storyType ? `Field "${source}" not found for work item type "${storyType}".` : `Field "${source}" not found in this project.`,
90069
+ code: "ESTIMATION_SOURCE_NOT_FOUND"
90070
+ });
90071
+ }
90072
+ return found;
90073
+ }
90074
+ function checkSourceValues(template, field, warnings) {
90075
+ const table = template.estimation?.conversion?.table;
90076
+ const multipliers = template.estimation?.conversion?.multipliers === true;
90077
+ const allowed = field.isPicklist ? field.allowedValues ?? [] : [];
90078
+ if (table && allowed.length > 0) {
90079
+ const keys = Object.keys(table);
90080
+ const uncovered = allowed.filter((value) => lookupTableEstimate(value, table, multipliers) === undefined);
90081
+ if (uncovered.length > 0) {
90082
+ warnings.push({
90083
+ path: "estimation.conversion.table",
90084
+ 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.`,
90085
+ suggestion: "Add the missing values to conversion.table."
90086
+ });
90087
+ }
90088
+ const usedThroughMultiplier = (key) => multipliers && allowed.some((value) => value !== key && lookupTableEstimate(value, { [key]: 1 }, true) !== undefined);
90089
+ const unknown2 = keys.filter((key) => !allowed.includes(key) && !usedThroughMultiplier(key));
90090
+ if (unknown2.length > 0) {
90091
+ warnings.push({
90092
+ path: "estimation.conversion.table",
90093
+ 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.`,
90094
+ suggestion: `Allowed values: ${listValues(allowed)}.`
90095
+ });
90096
+ }
90097
+ }
90098
+ if (field.type === "string" && field.allowsCustomValues) {
90099
+ 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.";
90100
+ warnings.push({
90101
+ path: "estimation.source",
90102
+ 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}`
90103
+ });
90104
+ }
90105
+ if (field.type === "string") {
90106
+ template.tasks.forEach((task, i2) => {
90107
+ const conditions = [
90108
+ [`tasks[${i2}].condition`, task.condition],
90109
+ ...(task.estimationPercentCondition ?? []).map((rule, j2) => [`tasks[${i2}].estimationPercentCondition[${j2}].condition`, rule.condition])
90110
+ ];
90111
+ for (const [path, condition] of conditions) {
90112
+ if (condition && usesNumericOperatorOnEstimation(condition)) {
90113
+ warnings.push({
90114
+ path,
90115
+ message: `This condition compares estimation numerically, but "${field.referenceName}" holds text values; it will skip the task for any non-numeric Story Estimate.`,
90116
+ suggestion: "Use equals or not-equals with the category values instead."
90117
+ });
90118
+ }
90119
+ }
90120
+ });
90121
+ }
90122
+ }
90123
+ function usesNumericOperatorOnEstimation(condition) {
90124
+ if ("all" in condition)
90125
+ return condition.all.some(usesNumericOperatorOnEstimation);
90126
+ if ("any" in condition)
90127
+ return condition.any.some(usesNumericOperatorOnEstimation);
90128
+ return "field" in condition && condition.field === "estimation" && NUMERIC_OPERATORS2.has(condition.operator);
90129
+ }
90130
+ function byReference(fields) {
90131
+ return new Map(fields.map((field) => [field.referenceName, field]));
90132
+ }
90133
+ function listValues(values) {
90134
+ return values.map((value) => `"${value}"`).join(", ");
90135
+ }
90136
+ var NUMERIC_TYPES, NUMERIC_OPERATORS2;
90137
+ var init_estimation_verifier = __esm(() => {
90138
+ init_schema2();
90139
+ NUMERIC_TYPES = new Set(["integer", "decimal"]);
90140
+ NUMERIC_OPERATORS2 = new Set(["gt", "lt", "gte", "lte"]);
90141
+ });
90142
+
89592
90143
  // ../atomize-core/src/templates/project-verifier.ts
89593
90144
  function analyzeTemplateProjectVerification(template) {
89594
90145
  const customFieldTaskCount = template.tasks.filter((task) => task.customFields && Object.keys(task.customFields).length > 0).length;
89595
90146
  const conditionFieldRefs = Array.from(new Set(template.tasks.flatMap((task) => task.condition ? extractCustomFieldRefs(task.condition) : [])));
89596
90147
  const hasSavedQuery = !!(template.filter?.savedQuery?.id || template.filter?.savedQuery?.path);
90148
+ const hasEstimationMapping = hasEstimationMappingOverrides(template);
89597
90149
  return {
89598
90150
  customFieldTaskCount,
89599
90151
  conditionFieldRefs,
89600
90152
  hasSavedQuery,
89601
- needsOnlineVerification: customFieldTaskCount > 0 || conditionFieldRefs.length > 0 || hasSavedQuery
90153
+ hasEstimationMapping,
90154
+ needsOnlineVerification: customFieldTaskCount > 0 || conditionFieldRefs.length > 0 || hasSavedQuery || hasEstimationMapping
89602
90155
  };
89603
90156
  }
89604
90157
  async function verifyTemplateProject(template, options) {
@@ -89615,12 +90168,28 @@ async function verifyTemplateProject(template, options) {
89615
90168
  return result;
89616
90169
  }
89617
90170
  if ((requirements.customFieldTaskCount > 0 || requirements.conditionFieldRefs.length > 0) && options.platform?.getFieldSchemas) {
89618
- const customFields = await verifyTemplateCustomFields(template, options.platform.getFieldSchemas.bind(options.platform));
90171
+ const customFields = await verifyTemplateCustomFields(template, options.platform.getFieldSchemas.bind(options.platform), template.taskType ?? options.platform.getEstimationDefaults?.().taskWorkItemType);
89619
90172
  result.errors.push(...customFields.errors);
89620
90173
  result.warnings.push(...customFields.warnings);
89621
90174
  if (customFields.errors.length > 0)
89622
90175
  result.valid = false;
89623
90176
  }
90177
+ const platform = options.platform;
90178
+ if (requirements.hasEstimationMapping && platform?.getFieldSchemas) {
90179
+ const estimation = await verifyEstimationMapping(template, {
90180
+ getFieldSchemas: platform.getFieldSchemas.bind(platform),
90181
+ getWorkItemTypes: platform.getWorkItemTypes?.bind(platform),
90182
+ getEstimationDefaults: platform.getEstimationDefaults?.bind(platform)
90183
+ });
90184
+ result.errors.push(...estimation.errors);
90185
+ if (options.strict === true || template.validation?.mode === "strict") {
90186
+ result.errors.push(...estimation.warnings.map((w2) => ({ path: w2.path, message: w2.message, suggestion: w2.suggestion, code: "STRICT_MODE_WARNING" })));
90187
+ } else {
90188
+ result.warnings.push(...estimation.warnings);
90189
+ }
90190
+ if (result.errors.length > 0)
90191
+ result.valid = false;
90192
+ }
89624
90193
  if (requirements.hasSavedQuery && options.platform?.listSavedQueries) {
89625
90194
  const savedQuery = await verifySavedQuery(template, options.platform);
89626
90195
  result.errors.push(...savedQuery.errors);
@@ -89676,6 +90245,7 @@ async function verifySavedQuery(template, platform) {
89676
90245
  var init_project_verifier = __esm(() => {
89677
90246
  init_condition_evaluator();
89678
90247
  init_custom_field_verifier();
90248
+ init_estimation_verifier();
89679
90249
  });
89680
90250
 
89681
90251
  // ../atomize-core/src/templates/template-verification.ts
@@ -92800,6 +93370,25 @@ function assertNotCancelled(value) {
92800
93370
  }
92801
93371
  return value;
92802
93372
  }
93373
+ function parseConversionTable(input2) {
93374
+ const entries = input2.split(",").map((entry) => entry.trim()).filter(Boolean);
93375
+ if (entries.length === 0)
93376
+ return "Enter at least one entry, e.g. S=2, M=4, L=5";
93377
+ const table = {};
93378
+ for (const entry of entries) {
93379
+ const separator = entry.lastIndexOf("=");
93380
+ const key = separator > 0 ? entry.slice(0, separator).trim() : "";
93381
+ const value = Number(entry.slice(separator + 1).trim());
93382
+ if (!key || separator === entry.length - 1 || Number.isNaN(value))
93383
+ return `"${entry}" is not in the form value=amount`;
93384
+ if (value < 0)
93385
+ return `"${key}" cannot have a negative amount`;
93386
+ if (Object.hasOwn(table, key))
93387
+ return `"${key}" appears more than once`;
93388
+ table[key] = value;
93389
+ }
93390
+ return table;
93391
+ }
92803
93392
  async function selectOrAutocomplete(opts) {
92804
93393
  const count = opts.thresholdCount ?? opts.options.length;
92805
93394
  if (count > AUTOCOMPLETE_THRESHOLD) {
@@ -92888,6 +93477,31 @@ var init_prompt_utilities = __esm(() => {
92888
93477
  return required2;
92889
93478
  return Validators.maxLength(fieldName, maxLength)(input2);
92890
93479
  },
93480
+ positiveInteger: (fieldName) => (input2) => {
93481
+ const num = Number(input2);
93482
+ if (!input2 || input2.trim() === "" || !Number.isInteger(num) || num < 1)
93483
+ return `${fieldName} must be a whole number of at least 1`;
93484
+ return;
93485
+ },
93486
+ positiveNumber: (fieldName) => (input2) => {
93487
+ const num = Number(input2);
93488
+ if (!input2 || input2.trim() === "" || Number.isNaN(num))
93489
+ return `${fieldName} must be a number`;
93490
+ if (num <= 0)
93491
+ return `${fieldName} must be greater than 0`;
93492
+ return;
93493
+ },
93494
+ fieldReferenceName: (input2) => {
93495
+ if (!input2 || input2.trim() === "")
93496
+ return "Field reference name is required";
93497
+ if (/\s/.test(input2.trim()))
93498
+ return "Use the field's reference name (e.g. Custom.TShirtSize), which has no spaces";
93499
+ return;
93500
+ },
93501
+ conversionTable: (input2) => {
93502
+ const parsed = parseConversionTable(input2 ?? "");
93503
+ return typeof parsed === "string" ? parsed : undefined;
93504
+ },
92891
93505
  estimationPercent: (input2) => {
92892
93506
  const num = Number(input2);
92893
93507
  if (Number.isNaN(num))
@@ -93201,9 +93815,19 @@ var init_profile_resolver = __esm(() => {
93201
93815
  init_keychain_service();
93202
93816
  });
93203
93817
 
93818
+ // ../atomize-core/src/services/template/estimation-grounding.ts
93819
+ function isEstimateCapableField(field, rules) {
93820
+ return isEstimateSourceField(field, rules);
93821
+ }
93822
+ var init_estimation_grounding = __esm(() => {
93823
+ init_src();
93824
+ init_src();
93825
+ });
93826
+
93204
93827
  // src/cli/commands/template/template-wizard-helper.command.ts
93205
93828
  var exports_template_wizard_helper_command = {};
93206
93829
  __export(exports_template_wizard_helper_command, {
93830
+ renderConversionTable: () => renderConversionTable,
93207
93831
  promptMixinRefs: () => promptMixinRefs,
93208
93832
  configureValidation: () => configureValidation,
93209
93833
  configureTemplateComposition: () => configureTemplateComposition,
@@ -93268,7 +93892,7 @@ async function configureFilter(ctx, defaults) {
93268
93892
  initialValue: defaultFilterMode
93269
93893
  }));
93270
93894
  if (filterMode === "savedQuery") {
93271
- return promptSavedQuery(ctx.savedQueries);
93895
+ return promptSavedQuery(ctx, defaults);
93272
93896
  }
93273
93897
  const filter = {};
93274
93898
  const workItemTypes = await promptWorkItemTypes(ctx, defaults?.workItemTypes);
@@ -93353,8 +93977,8 @@ async function configureFilter(ctx, defaults) {
93353
93977
  }
93354
93978
  return filter;
93355
93979
  }
93356
- async function promptSavedQuery(queries) {
93357
- const sorted = [...queries].sort((a3, b3) => {
93980
+ async function promptSavedQuery(ctx, defaults) {
93981
+ const sorted = [...ctx.savedQueries].sort((a3, b3) => {
93358
93982
  if (a3.isPublic !== b3.isPublic)
93359
93983
  return a3.isPublic ? -1 : 1;
93360
93984
  return a3.path.localeCompare(b3.path);
@@ -93368,16 +93992,21 @@ async function promptSavedQuery(queries) {
93368
93992
  })),
93369
93993
  placeholder: "Type to filter by path or name..."
93370
93994
  });
93995
+ const workItemTypes = await promptWorkItemTypes(ctx, defaults?.workItemTypes, {
93996
+ message: "Which work item types does this query return? (used to look up their fields, not to filter)",
93997
+ required: true
93998
+ });
93371
93999
  const excludeIfHasTasks = assertNotCancelled(await confirm({
93372
94000
  message: "Exclude work items that already have tasks?",
93373
94001
  initialValue: true
93374
94002
  }));
93375
94003
  return {
93376
94004
  savedQuery: { id: selectedId.trim() },
94005
+ workItemTypes,
93377
94006
  ...excludeIfHasTasks && { excludeIfHasTasks: true }
93378
94007
  };
93379
94008
  }
93380
- async function promptWorkItemTypes(ctx, defaults) {
94009
+ async function promptWorkItemTypes(ctx, defaults, options = {}) {
93381
94010
  const defaultType = ctx.workItemTypes.includes("User Story") ? "User Story" : undefined;
93382
94011
  const sorted = [
93383
94012
  ...defaultType ? [defaultType] : [],
@@ -93385,10 +94014,10 @@ async function promptWorkItemTypes(ctx, defaults) {
93385
94014
  ];
93386
94015
  const initialValues = defaults && defaults.length > 0 ? defaults.filter((t3) => ctx.workItemTypes.includes(t3)) : defaultType ? [defaultType] : [];
93387
94016
  const selected = assertNotCancelled(await multiselect({
93388
- message: "Select work item types:",
94017
+ message: options.message ?? "Select work item types:",
93389
94018
  options: sorted.map((t3) => ({ label: t3, value: t3 })),
93390
94019
  initialValues,
93391
- required: false
94020
+ required: options.required ?? false
93392
94021
  }));
93393
94022
  return selected;
93394
94023
  }
@@ -93681,7 +94310,12 @@ async function promptDateFilter(selectMessage, customMessage, defaults) {
93681
94310
  }
93682
94311
  return choice;
93683
94312
  }
93684
- async function configureEstimation(defaults) {
94313
+ async function configureEstimation(defaults, ctx = {}, currentTaskType) {
94314
+ const source = await promptEstimationSource(defaults?.source, ctx);
94315
+ const sourceField = source ? ctx.storyFields?.find((field) => field.referenceName === source) : undefined;
94316
+ const conversion = await promptConversion(defaults?.conversion, sourceField);
94317
+ const sourceValues = sourceField?.isPicklist ? sourceField.allowedValues ?? [] : [];
94318
+ const { taskType, targetFields } = await promptEstimateTarget(currentTaskType, defaults?.targetFields, ctx);
93685
94319
  const rounding = assertNotCancelled(await select({
93686
94320
  message: "Rounding strategy:",
93687
94321
  options: [
@@ -93692,23 +94326,267 @@ async function configureEstimation(defaults) {
93692
94326
  ],
93693
94327
  initialValue: defaults?.rounding ?? "none"
93694
94328
  }));
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
- }
94329
+ const minimumTaskEstimateRaw = assertNotCancelled(await text({
94330
+ message: "Minimum Task Estimate, in the Task's unit (0 for no minimum):",
94331
+ initialValue: String(defaults?.minimumTaskEstimate ?? defaults?.minimumTaskPoints ?? 0),
94332
+ validate: Validators.nonNegative("Minimum Task Estimate")
93706
94333
  }));
93707
- return {
94334
+ const normalize = assertNotCancelled(await select({
94335
+ message: "When the generated tasks' percentages don't add up to 100%:",
94336
+ options: [
94337
+ { label: "Fill up to 100% (scale up below 100%, keep anything above)", value: "auto" },
94338
+ { label: "Always scale to exactly 100%", value: "always" },
94339
+ { label: "Use the percentages as written", value: "never" }
94340
+ ],
94341
+ initialValue: defaults?.normalize ?? "auto"
94342
+ }));
94343
+ const policy = assertNotCancelled(await select({
94344
+ message: "When a Story's estimate is missing or can't be converted:",
94345
+ options: [
94346
+ { label: "Create the tasks with blank estimates and warn", value: "warn" },
94347
+ { label: "Skip the Story", value: "skip" },
94348
+ { label: "Assume a default Story Estimate", value: "use-default" }
94349
+ ],
94350
+ initialValue: defaults?.ifParentHasNoEstimation ?? "warn"
94351
+ }));
94352
+ const defaultParentEstimation = policy === "use-default" ? await promptDefaultStoryEstimate(defaults?.defaultParentEstimation, conversion, sourceValues) : undefined;
94353
+ const {
94354
+ source: _source,
94355
+ conversion: _conversion,
94356
+ targetFields: _targetFields,
94357
+ minimumTaskPoints: _minimumTaskPoints,
94358
+ defaultParentEstimation: _default3,
94359
+ ifParentHasNoEstimation: _policy,
94360
+ normalize: _normalize2,
94361
+ ...kept
94362
+ } = defaults ?? {};
94363
+ const estimation = {
94364
+ ...kept,
93708
94365
  strategy: "percentage",
93709
94366
  rounding,
93710
- minimumTaskPoints: Number(minimumTaskPointsRaw) || undefined
93711
- };
94367
+ minimumTaskEstimate: Number(minimumTaskEstimateRaw) || undefined,
94368
+ ...source ? { source } : {},
94369
+ ...conversion ? { conversion } : {},
94370
+ ...targetFields?.length ? { targetFields } : {},
94371
+ ...normalize !== "auto" ? { normalize } : {},
94372
+ ...policy !== "warn" ? { ifParentHasNoEstimation: policy } : {},
94373
+ ...defaultParentEstimation !== undefined ? { defaultParentEstimation } : {}
94374
+ };
94375
+ if (estimation.minimumTaskEstimate === undefined)
94376
+ delete estimation.minimumTaskEstimate;
94377
+ return { estimation, ...taskType ? { taskType } : {} };
94378
+ }
94379
+ async function promptEstimationSource(current, ctx) {
94380
+ const rules = { storyEstimateFields: ctx.defaults?.storyEstimateFields ?? [], nonEstimateFields: ctx.defaults?.nonEstimateFields };
94381
+ const candidates = sortEstimateFields((ctx.storyFields ?? []).filter((field) => isEstimateCapableField(field, rules)));
94382
+ if (ctx.storyFields !== undefined) {
94383
+ const choice = assertNotCancelled(await select({
94384
+ message: "Which Story field holds the Story Estimate?",
94385
+ options: [
94386
+ { label: "Platform default (story points, effort or size)", value: "" },
94387
+ ...candidates.map((field) => ({
94388
+ label: `${field.name} (${field.referenceName})`,
94389
+ value: field.referenceName,
94390
+ hint: field.isPicklist ? `values: ${(field.allowedValues ?? []).join(", ")}` : field.type
94391
+ })),
94392
+ { label: "Another field (enter its reference name)", value: CUSTOM_ENTRY }
94393
+ ],
94394
+ initialValue: current && candidates.some((field) => field.referenceName === current) ? current : current ? CUSTOM_ENTRY : ""
94395
+ }));
94396
+ if (choice !== CUSTOM_ENTRY)
94397
+ return choice || undefined;
94398
+ }
94399
+ const typed = assertNotCancelled(await text({
94400
+ message: "Story field reference name holding the Story Estimate (leave blank for the platform default):",
94401
+ initialValue: current ?? "",
94402
+ validate: (input2) => input2 && input2.trim() !== "" ? Validators.fieldReferenceName(input2) : undefined
94403
+ }));
94404
+ return typed.trim() || undefined;
94405
+ }
94406
+ async function promptConversion(current, sourceField) {
94407
+ const kind = assertNotCancelled(await select({
94408
+ message: "How does the Story Estimate translate into Task hours?",
94409
+ options: [
94410
+ { label: "One-to-one (use the number as it is)", value: "none" },
94411
+ { label: "A factor (e.g. 1 point = 4 hours)", value: "factor" },
94412
+ { label: "A table (e.g. t-shirt sizes, or points that don't scale linearly)", value: "table" }
94413
+ ],
94414
+ initialValue: current?.table ? "table" : current?.factor !== undefined ? "factor" : sourceField?.isPicklist ? "table" : "none"
94415
+ }));
94416
+ if (kind === "none")
94417
+ return;
94418
+ if (kind === "factor") {
94419
+ const factor = assertNotCancelled(await text({
94420
+ message: "Task hours per unit of Story Estimate:",
94421
+ initialValue: current?.factor !== undefined ? String(current.factor) : "",
94422
+ validate: Validators.positiveNumber("Factor")
94423
+ }));
94424
+ return { factor: Number(factor) };
94425
+ }
94426
+ const table = await promptConversionTable(current?.table, sourceField);
94427
+ const keysAreCategories = !Object.keys(table).every((key) => /^(?:\d+(?:\.\d+)?|\.\d+)$/.test(key));
94428
+ const multipliers = keysAreCategories && assertNotCancelled(await confirm({
94429
+ message: "Also accept multiples of a size, e.g. 0.3XL = 0.3 × XL?",
94430
+ initialValue: current?.multipliers === true
94431
+ }));
94432
+ return { table, ...multipliers ? { multipliers: true } : {} };
94433
+ }
94434
+ function renderConversionTable(table, source) {
94435
+ const allowed = source?.allowedValues ?? [];
94436
+ const entries = Object.entries(table);
94437
+ const heading = `Conversion table — ${source?.name ?? "Story Estimate"} → hours`;
94438
+ const lines = [allowed.length ? `${heading} ${allowed.filter((v2) => Object.hasOwn(table, v2)).length} of ${allowed.length} values covered` : heading];
94439
+ if (entries.length === 0) {
94440
+ lines.push(" (no values yet)");
94441
+ } else {
94442
+ const width = Math.max(5, ...entries.map(([key]) => key.length));
94443
+ lines.push(` ${"Value".padEnd(width)} ${"Hours".padEnd(6)} A 20% task gets`);
94444
+ for (const [key, hours] of entries) {
94445
+ const flag = allowed.length && !allowed.includes(key) ? " not in picklist" : "";
94446
+ lines.push(` ${key.padEnd(width)} ${String(hours).padEnd(6)} ${Math.round(hours * 20) / 100} h${flag}`);
94447
+ }
94448
+ }
94449
+ const missing = allowed.filter((value) => !Object.hasOwn(table, value));
94450
+ if (missing.length)
94451
+ lines.push(` Missing: ${missing.join(", ")} — Stories with ${missing.length === 1 ? "this value" : "these values"} get blank estimates.`);
94452
+ return lines;
94453
+ }
94454
+ async function promptConversionTable(current, sourceField) {
94455
+ const allowed = sourceField?.isPicklist ? sourceField.allowedValues ?? [] : [];
94456
+ const table = { ...current ?? {} };
94457
+ if (allowed.length && !current) {
94458
+ output3.print(source_default.gray(`Enter the Task hours for each ${sourceField?.name ?? "value"}. Leave blank to skip a value.`));
94459
+ for (const [index, value] of allowed.entries()) {
94460
+ const hours = assertNotCancelled(await text({
94461
+ message: `Hours for "${value}" (${index + 1} of ${allowed.length}):`,
94462
+ validate: (input2) => input2 && input2.trim() !== "" ? Validators.nonNegative("Hours")(input2) : undefined
94463
+ }));
94464
+ if (hours.trim() !== "")
94465
+ table[value] = Number(hours);
94466
+ }
94467
+ }
94468
+ for (;; ) {
94469
+ output3.blankLine();
94470
+ for (const line of renderConversionTable(table, sourceField))
94471
+ output3.print(source_default.gray(line));
94472
+ const missing = allowed.filter((value) => !Object.hasOwn(table, value));
94473
+ const keys = Object.keys(table);
94474
+ const action = assertNotCancelled(await select({
94475
+ message: "Conversion table",
94476
+ options: [
94477
+ ...missing.map((value) => ({ label: `Add hours for ${value}`, value: `add:${value}` })),
94478
+ { label: allowed.length ? "Add another value…" : "Add a value", value: "add" },
94479
+ ...keys.length ? [{ label: "Change a value's hours", value: "change" }, { label: "Remove a value", value: "remove" }] : [],
94480
+ { label: "Paste several (e.g. S=2, M=4)", value: "paste" },
94481
+ { label: "Done", value: "done" }
94482
+ ],
94483
+ initialValue: missing.length ? `add:${missing[0]}` : keys.length ? "done" : "add"
94484
+ }));
94485
+ if (action === "done") {
94486
+ if (keys.length)
94487
+ return table;
94488
+ output3.print(source_default.yellow("Add at least one value before finishing."));
94489
+ continue;
94490
+ }
94491
+ if (action.startsWith("add:")) {
94492
+ const value = action.slice(4);
94493
+ table[value] = Number(assertNotCancelled(await text({ message: `Hours for "${value}":`, validate: hoursValidator })));
94494
+ continue;
94495
+ }
94496
+ if (action === "add") {
94497
+ const value = assertNotCancelled(await text({
94498
+ message: "Story Estimate value (e.g. XL, or 8 for points):",
94499
+ validate: (input2) => {
94500
+ if (!input2 || input2.trim() === "")
94501
+ return "Enter a value";
94502
+ if (Object.hasOwn(table, input2.trim()))
94503
+ return `"${input2.trim()}" is already in the table`;
94504
+ return;
94505
+ }
94506
+ })).trim();
94507
+ table[value] = Number(assertNotCancelled(await text({ message: `Hours for "${value}":`, validate: hoursValidator })));
94508
+ continue;
94509
+ }
94510
+ if (action === "change") {
94511
+ const value = assertNotCancelled(await select({ message: "Which value?", options: keys.map((key) => ({ label: `${key} (${table[key]} h)`, value: key })) }));
94512
+ table[value] = Number(assertNotCancelled(await text({ message: `Hours for "${value}":`, initialValue: String(table[value]), validate: hoursValidator })));
94513
+ continue;
94514
+ }
94515
+ if (action === "remove") {
94516
+ const value = assertNotCancelled(await select({ message: "Remove which value?", options: keys.map((key) => ({ label: `${key} (${table[key]} h)`, value: key })) }));
94517
+ delete table[value];
94518
+ continue;
94519
+ }
94520
+ const pasted = assertNotCancelled(await text({ message: "Value=hours pairs, comma-separated (e.g. S=2, M=4, L=5):", validate: Validators.conversionTable }));
94521
+ Object.assign(table, parseConversionTable(pasted));
94522
+ }
94523
+ }
94524
+ async function promptEstimateTarget(currentTaskType, currentTargetFields, ctx) {
94525
+ const customise = assertNotCancelled(await confirm({
94526
+ message: `Create tasks as a different work item type, or write estimates to different fields? (default: ${ctx.defaults?.taskWorkItemType ?? "platform default"})`,
94527
+ initialValue: currentTaskType !== undefined || (currentTargetFields?.length ?? 0) > 0
94528
+ }));
94529
+ if (!customise)
94530
+ return {};
94531
+ const defaultType = ctx.defaults?.taskWorkItemType;
94532
+ let taskType;
94533
+ if (ctx.workItemTypes?.length) {
94534
+ taskType = assertNotCancelled(await select({
94535
+ message: "Work item type to create tasks as:",
94536
+ options: ctx.workItemTypes.map((type) => ({ label: type, value: type, hint: type === defaultType ? "platform default" : undefined })),
94537
+ initialValue: currentTaskType ?? defaultType ?? ctx.workItemTypes[0]
94538
+ }));
94539
+ } else {
94540
+ taskType = assertNotCancelled(await text({
94541
+ message: "Work item type to create tasks as:",
94542
+ initialValue: currentTaskType ?? defaultType ?? "",
94543
+ validate: Validators.required("Work item type")
94544
+ })).trim();
94545
+ }
94546
+ const numericFields = ctx.getTaskFields ? sortEstimateFields((await ctx.getTaskFields(taskType)).filter((field) => isEstimateTargetField(field, ctx.defaults))) : [];
94547
+ let targetFields;
94548
+ if (numericFields.length > 0) {
94549
+ targetFields = assertNotCancelled(await multiselect({
94550
+ message: "Task fields that receive the estimate (none selected = platform default fields):",
94551
+ options: numericFields.map((field) => ({ label: `${field.name} (${field.referenceName})`, value: field.referenceName })),
94552
+ initialValues: currentTargetFields ?? [],
94553
+ required: false
94554
+ }));
94555
+ } else {
94556
+ const typed = assertNotCancelled(await text({
94557
+ message: "Task field reference names that receive the estimate, comma-separated (blank = platform default fields):",
94558
+ initialValue: currentTargetFields?.join(", ") ?? ""
94559
+ }));
94560
+ targetFields = Filters.commaSeparated(typed).filter(Boolean);
94561
+ }
94562
+ const keepTaskType = taskType !== defaultType || currentTaskType !== undefined;
94563
+ return { ...keepTaskType ? { taskType } : {}, ...targetFields.length ? { targetFields } : {} };
94564
+ }
94565
+ async function promptDefaultStoryEstimate(current, conversion, sourceValues) {
94566
+ const choices = (sourceValues.length ? sourceValues : Object.keys(conversion?.table ?? {})).filter((value) => isConvertibleStoryEstimate(value, conversion));
94567
+ if (choices.length > 0) {
94568
+ const currentValue = current === undefined ? undefined : String(current);
94569
+ const choice = assertNotCancelled(await select({
94570
+ message: "Default Story Estimate to assume:",
94571
+ options: [
94572
+ ...choices.map((value) => ({ label: value, value })),
94573
+ ...conversion?.multipliers ? [{ label: "Another value (e.g. 0.5L)", value: CUSTOM_ENTRY }] : []
94574
+ ],
94575
+ initialValue: currentValue && choices.includes(currentValue) ? currentValue : currentValue && conversion?.multipliers ? CUSTOM_ENTRY : choices[0]
94576
+ }));
94577
+ if (choice !== CUSTOM_ENTRY)
94578
+ return choice;
94579
+ }
94580
+ const typed = assertNotCancelled(await text({
94581
+ message: "Default Story Estimate to assume (e.g. 5, or a size like M):",
94582
+ initialValue: current !== undefined ? String(current) : "",
94583
+ validate: (input2) => {
94584
+ if (!input2 || input2.trim() === "")
94585
+ return "A default Story Estimate is required";
94586
+ 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";
94587
+ }
94588
+ })).trim();
94589
+ return Number.isFinite(Number(typed)) ? Number(typed) : typed;
93712
94590
  }
93713
94591
  async function configureValidation(defaults) {
93714
94592
  const validation = {};
@@ -93953,9 +94831,15 @@ async function promptMixinRefs(mixins) {
93953
94831
  function formatCatalogChoice(item) {
93954
94832
  return `${item.displayName} (${item.scope})`;
93955
94833
  }
93956
- var output3, ADVANCED_GROUP_ORDER, DATE_FILTER_OPTIONS;
94834
+ var output3, ADVANCED_GROUP_ORDER, DATE_FILTER_OPTIONS, CUSTOM_ENTRY = "__custom__", hoursValidator = (input2) => {
94835
+ if (!input2 || input2.trim() === "")
94836
+ return "Enter the hours";
94837
+ return Validators.nonNegative("Hours")(input2);
94838
+ };
93957
94839
  var init_template_wizard_helper_command = __esm(() => {
93958
94840
  init_dist6();
94841
+ init_estimation_grounding();
94842
+ init_schema2();
93959
94843
  init_source();
93960
94844
  init_command_output();
93961
94845
  init_prompt_utilities();
@@ -97894,7 +98778,7 @@ var program = new Command;
97894
98778
 
97895
98779
  // package.json
97896
98780
  var name = "@sppg2001/atomize";
97897
- var version2 = "4.0.1";
98781
+ var version2 = "4.2.0";
97898
98782
 
97899
98783
  // src/cli/commands/auth/auth-add.command.ts
97900
98784
  init_dist6();
@@ -98850,6 +99734,7 @@ init_dist6();
98850
99734
  init_logger2();
98851
99735
  init_estimation_distribution();
98852
99736
  init_platform_factory();
99737
+ init_schema2();
98853
99738
  init_template_library();
98854
99739
  init_errors();
98855
99740
 
@@ -99396,16 +100281,26 @@ function printReport(report, options, dryRun, storyIds) {
99396
100281
  `));
99397
100282
  for (const result of report.results) {
99398
100283
  if (result.success) {
100284
+ if (result.skipReason) {
100285
+ output2.print(source_default.yellow(`⊘ ${sanitizeTty(result.story.id)}: ${sanitizeTty(result.story.title)}`));
100286
+ output2.print(source_default.gray(` Skipped: ${sanitizeTty(result.skipReason)}`));
100287
+ continue;
100288
+ }
99399
100289
  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`));
100290
+ const withUnit = (value) => result.estimateUnit ? `${formatEstimateValue(value)} ${result.estimateUnit}` : formatEstimateValue(value);
100291
+ const rawEstimate = result.storyEstimate;
100292
+ const converted = result.estimationSummary?.storyEstimation;
100293
+ const conversionNote = converted !== undefined && converted !== rawEstimate ? ` (converted to ${withUnit(converted)})` : "";
100294
+ output2.print(source_default.gray(` Estimation: ${rawEstimate === undefined ? "none" : sanitizeTty(String(rawEstimate))}${conversionNote}`));
99401
100295
  output2.print(source_default.gray(` Tasks: ${result.tasksCalculated.length}`));
99402
100296
  if (result.estimationSummary) {
99403
- output2.print(source_default.gray(` Distribution: ${result.estimationSummary.totalTaskEstimation} points (${result.estimationSummary.percentageUsed.toFixed(0)}%)`));
100297
+ output2.print(source_default.gray(` Distribution: ${withUnit(result.estimationSummary.totalTaskEstimation)} (${result.estimationSummary.percentageUsed.toFixed(0)}%)`));
99404
100298
  }
99405
100299
  if ((options.verbose || dryRun) && result.tasksCalculated.length > 0) {
99406
100300
  output2.print(source_default.gray(" Task breakdown:"));
99407
100301
  for (const task of result.tasksCalculated) {
99408
- output2.print(source_default.gray(` - ${sanitizeTty(task.title)}: ${task.estimation} points (${task.estimationPercent}%)`));
100302
+ const estimate = task.estimation === undefined ? "unestimated" : withUnit(task.estimation);
100303
+ output2.print(source_default.gray(` - ${sanitizeTty(task.title)}: ${estimate} (${task.estimationPercent}%)`));
99409
100304
  }
99410
100305
  }
99411
100306
  } else {
@@ -99577,6 +100472,8 @@ function makeGenerateCommand(makeOutput, prompts, config2) {
99577
100472
  });
99578
100473
  }
99579
100474
  async function resolveNormalization(template, isTTYSession, prompts) {
100475
+ if (template.estimation?.normalize && template.estimation.normalize !== "auto")
100476
+ return false;
99580
100477
  const { shouldOffer, total } = shouldOfferOverageNormalization(template.tasks);
99581
100478
  if (!shouldOffer)
99582
100479
  return false;
@@ -99594,6 +100491,7 @@ async function resolveNormalization(template, isTTYSession, prompts) {
99594
100491
  init_condition_evaluator();
99595
100492
  init_dependency_resolver();
99596
100493
  init_estimation_calculator();
100494
+ init_mock_adapter();
99597
100495
  var STANDARD_FIELD_TYPES = {
99598
100496
  id: "string",
99599
100497
  title: "string",
@@ -99652,7 +100550,11 @@ function inspectTemplate(template) {
99652
100550
  }
99653
100551
  }
99654
100552
  if (template.tasks.some((task) => task.estimationPercent !== undefined || task.estimationPercentCondition?.length)) {
99655
- upsert("estimation", "number", "estimation", { required: true });
100553
+ const source = template.estimation?.source;
100554
+ if (source)
100555
+ upsert(source, "unknown", "estimation", { required: true });
100556
+ else
100557
+ upsert("estimation", "number", "estimation", { required: true });
99656
100558
  }
99657
100559
  for (const name2 of filterFields) {
99658
100560
  upsert(name2, STANDARD_FIELD_TYPES[name2] ?? "unknown", "filter");
@@ -99695,13 +100597,16 @@ function runPreview(template, mockStoryJson) {
99695
100597
  const story = parseMockStory(mockStoryJson);
99696
100598
  const orderedTasks = new DependencyResolver().resolveDependencies(template.tasks);
99697
100599
  const calc = new EstimationCalculator;
99698
- const { calculatedTasks, skippedTasks } = calc.calculateTasksWithSkipped(story, "", orderedTasks, template.estimation);
99699
- const summary = calc.getEstimationSummary(story, calculatedTasks);
100600
+ const fieldMapping = resolveEstimationFieldMapping(MOCK_ESTIMATION_DEFAULTS, {
100601
+ source: template.estimation?.source,
100602
+ targetFields: template.estimation?.targetFields
100603
+ });
100604
+ const { calculatedTasks, skippedTasks, unresolvedEstimate } = calc.calculateTasksWithSkipped(story, "", orderedTasks, template.estimation, false, fieldMapping);
100605
+ const summary = calc.getEstimationSummary(story, calculatedTasks, template.estimation, fieldMapping);
99700
100606
  const tasks = calculatedTasks.map((task) => {
99701
- const result = {
99702
- title: task.title,
99703
- estimation: task.estimation ?? 0
99704
- };
100607
+ const result = { title: task.title };
100608
+ if (task.estimation !== undefined)
100609
+ result.estimation = task.estimation;
99705
100610
  if (task.estimationPercent !== undefined)
99706
100611
  result.estimationPercent = task.estimationPercent;
99707
100612
  if (task.dependsOn?.length)
@@ -99720,6 +100625,8 @@ function runPreview(template, mockStoryJson) {
99720
100625
  title: s2.templateTask.title,
99721
100626
  reason: s2.reason
99722
100627
  })),
100628
+ ...unresolvedEstimate ? { unresolvedEstimate } : {},
100629
+ ...fieldMapping.unitLabel ? { estimateUnit: fieldMapping.unitLabel } : {},
99723
100630
  estimationSummary: {
99724
100631
  storyEstimation: summary.storyEstimation,
99725
100632
  totalTaskEstimation: summary.totalTaskEstimation,
@@ -100041,6 +100948,70 @@ init_errors();
100041
100948
  // ../atomize-core/src/services/template/learned-template-product.ts
100042
100949
  init_estimation_distribution();
100043
100950
 
100951
+ // ../atomize-core/src/core/estimation-inference.ts
100952
+ function taskEstimateShares(taskEstimates) {
100953
+ const total = taskEstimates.reduce((sum, value) => sum + (value ?? 0), 0);
100954
+ if (total <= 0)
100955
+ return taskEstimates.map(() => 0);
100956
+ return taskEstimates.map((value) => Math.round((value ?? 0) / total * 100));
100957
+ }
100958
+ function inferEstimation(samples) {
100959
+ const usable = samples.map((sample) => ({ estimate: sample.storyEstimate, total: sum(sample.taskEstimates) })).filter((s2) => s2.estimate !== undefined && s2.total > 0);
100960
+ if (usable.length === 0)
100961
+ return { lowConfidenceValues: [] };
100962
+ const defaultStoryEstimate = mostCommon(usable.map((s2) => s2.estimate));
100963
+ const allNumeric = usable.every((s2) => numericStoryEstimate(s2.estimate) !== undefined);
100964
+ if (allNumeric) {
100965
+ const ratios = usable.map((s2) => ({ points: numericStoryEstimate(s2.estimate) ?? 0, total: s2.total })).filter((s2) => s2.points > 0).map((s2) => s2.total / s2.points);
100966
+ if (ratios.length === 0)
100967
+ return { defaultStoryEstimate, lowConfidenceValues: [] };
100968
+ const factor = roundTo(median(ratios), 2);
100969
+ return {
100970
+ ...factor !== 1 ? { conversion: { factor } } : {},
100971
+ defaultStoryEstimate,
100972
+ lowConfidenceValues: []
100973
+ };
100974
+ }
100975
+ const totalsByValue = new Map;
100976
+ for (const s2 of usable) {
100977
+ const key = String(s2.estimate).trim();
100978
+ totalsByValue.set(key, [...totalsByValue.get(key) ?? [], s2.total]);
100979
+ }
100980
+ const table = {};
100981
+ const lowConfidenceValues = [];
100982
+ for (const [key, totals] of totalsByValue) {
100983
+ table[key] = roundTo(median(totals), 2);
100984
+ if (totals.length === 1)
100985
+ lowConfidenceValues.push(key);
100986
+ }
100987
+ return { conversion: { table }, defaultStoryEstimate, lowConfidenceValues };
100988
+ }
100989
+ function sum(values) {
100990
+ return values.reduce((total, value) => total + (value ?? 0), 0);
100991
+ }
100992
+ function median(values) {
100993
+ const sorted = [...values].sort((a3, b3) => a3 - b3);
100994
+ const mid = Math.floor(sorted.length / 2);
100995
+ return sorted.length % 2 === 0 ? ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2 : sorted[mid] ?? 0;
100996
+ }
100997
+ function mostCommon(values) {
100998
+ const counts = new Map;
100999
+ for (const value of values)
101000
+ counts.set(value, (counts.get(value) ?? 0) + 1);
101001
+ let best;
101002
+ let bestCount = 0;
101003
+ for (const [value, count] of counts) {
101004
+ if (count > bestCount) {
101005
+ best = value;
101006
+ bestCount = count;
101007
+ }
101008
+ }
101009
+ return best;
101010
+ }
101011
+ function roundTo(value, decimals) {
101012
+ const scale = 10 ** decimals;
101013
+ return Math.round(value * scale) / scale;
101014
+ }
100044
101015
  // ../atomize-core/src/services/template/pattern-detection.ts
100045
101016
  init_dist();
100046
101017
  var DEFAULT_PATTERN_SCORING_CONFIG = {
@@ -100631,12 +101602,12 @@ class ConditionPatternDetector {
100631
101602
  }
100632
101603
  detectEstimationConditions(analyses, commonTasks, threshold) {
100633
101604
  const patterns = [];
100634
- const estimations = analyses.map((a3) => a3.story.estimation).filter((e2) => e2 !== undefined && e2 > 0).sort((a3, b3) => a3 - b3);
101605
+ const estimations = analyses.map((a3) => numericStoryEstimate(a3.story.estimation)).filter((e2) => e2 !== undefined && e2 > 0).sort((a3, b3) => a3 - b3);
100635
101606
  if (estimations.length < this.minSampleSize)
100636
101607
  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);
101608
+ const median2 = estimations[Math.floor(estimations.length / 2)] ?? 0;
101609
+ const q75 = estimations[Math.floor(estimations.length * 3 / 4)] ?? median2;
101610
+ const thresholds = [...new Set([median2, q75])].filter((t3) => t3 > 0);
100640
101611
  for (const commonTask of commonTasks) {
100641
101612
  if (commonTask.frequencyRatio >= 0.9)
100642
101613
  continue;
@@ -100666,7 +101637,7 @@ class ConditionPatternDetector {
100666
101637
  let taskInLargeStories = 0;
100667
101638
  let taskInSmallStories = 0;
100668
101639
  for (const analysis of analyses) {
100669
- const estimation = analysis.story.estimation;
101640
+ const estimation = numericStoryEstimate(analysis.story.estimation);
100670
101641
  if (estimation === undefined)
100671
101642
  continue;
100672
101643
  const isLarge = estimation >= estimationThreshold;
@@ -100779,7 +101750,7 @@ class ConditionPatternDetector {
100779
101750
  return false;
100780
101751
  }
100781
101752
  case "estimation": {
100782
- const e2 = story.estimation;
101753
+ const e2 = numericStoryEstimate(story.estimation);
100783
101754
  if (e2 === undefined)
100784
101755
  return false;
100785
101756
  const v2 = condition.value;
@@ -100950,18 +101921,18 @@ class FilterLearner {
100950
101921
  for (const p3 of priorities) {
100951
101922
  priorityCounts.set(p3, (priorityCounts.get(p3) ?? 0) + 1);
100952
101923
  }
100953
- let mostCommon = min;
101924
+ let mostCommon2 = min;
100954
101925
  let maxCount = 0;
100955
101926
  for (const [priority, count] of priorityCounts) {
100956
101927
  if (count > maxCount) {
100957
- mostCommon = priority;
101928
+ mostCommon2 = priority;
100958
101929
  maxCount = count;
100959
101930
  }
100960
101931
  }
100961
- return { min, max, mostCommon };
101932
+ return { min, max, mostCommon: mostCommon2 };
100962
101933
  }
100963
101934
  detectEstimationRange(analyses) {
100964
- const estimations = analyses.map((a3) => a3.story.estimation).filter((e2) => e2 !== undefined && e2 > 0);
101935
+ const estimations = analyses.map((a3) => numericStoryEstimate(a3.story.estimation)).filter((e2) => e2 !== undefined && e2 > 0);
100965
101936
  if (estimations.length === 0)
100966
101937
  return;
100967
101938
  const sorted = [...estimations].sort((a3, b3) => a3 - b3);
@@ -101042,7 +102013,7 @@ class FilterLearner {
101042
102013
  if (max - min <= 3) {
101043
102014
  suggestions.push({
101044
102015
  type: "improve-filter",
101045
- message: `Stories have similar estimations (${min}-${max} points, avg ${average}). This template works well for stories of this size.`,
102016
+ message: `Stories have similar estimations (${min}-${max}, avg ${average}). This template works well for stories of this size.`,
101046
102017
  severity: "info"
101047
102018
  });
101048
102019
  }
@@ -101065,8 +102036,8 @@ class FilterLearner {
101065
102036
  parts.push(`Area paths: ${learnedFilters.areaPaths.values.join(", ")}`);
101066
102037
  }
101067
102038
  if (learnedFilters.priorityRange) {
101068
- const { min, max, mostCommon } = learnedFilters.priorityRange;
101069
- parts.push(`Priority range: ${min}-${max} (most common: ${mostCommon})`);
102039
+ const { min, max, mostCommon: mostCommon2 } = learnedFilters.priorityRange;
102040
+ parts.push(`Priority range: ${min}-${max} (most common: ${mostCommon2})`);
101070
102041
  }
101071
102042
  if (learnedFilters.estimationRange) {
101072
102043
  const { min, max, average } = learnedFilters.estimationRange;
@@ -101215,7 +102186,7 @@ class PatternDetector extends SimilarityCalculator {
101215
102186
  detectEstimationPattern(analyses) {
101216
102187
  const totalEstimations = [];
101217
102188
  for (const analysis of analyses) {
101218
- const storyEstimation = analysis.story.estimation ?? 0;
102189
+ const storyEstimation = numericStoryEstimate(analysis.story.estimation) ?? 0;
101219
102190
  totalEstimations.push(storyEstimation);
101220
102191
  }
101221
102192
  const avgTotal = totalEstimations.length > 0 ? totalEstimations.reduce((a3, b3) => a3 + b3, 0) / totalEstimations.length : 0;
@@ -101297,7 +102268,7 @@ class ConfidenceScorer {
101297
102268
  };
101298
102269
  }
101299
102270
  scoreTaskConsistency(analyses, patterns) {
101300
- const totalTasks = analyses.reduce((sum, a3) => sum + a3.tasks.length, 0);
102271
+ const totalTasks = analyses.reduce((sum2, a3) => sum2 + a3.tasks.length, 0);
101301
102272
  if (totalTasks === 0) {
101302
102273
  return {
101303
102274
  name: "Task Consistency",
@@ -101377,9 +102348,9 @@ class ConfidenceScorer {
101377
102348
  description: "No tasks to merge"
101378
102349
  };
101379
102350
  }
101380
- const totalOriginalTasks = analyses.reduce((sum, a3) => sum + a3.tasks.length, 0);
102351
+ const totalOriginalTasks = analyses.reduce((sum2, a3) => sum2 + a3.tasks.length, 0);
101381
102352
  const mergeRatio = totalOriginalTasks > 0 ? 1 - mergedTasks.length / totalOriginalTasks : 0;
101382
- const avgSimilarity = mergedTasks.reduce((sum, m3) => sum + m3.similarity, 0) / mergedTasks.length;
102353
+ const avgSimilarity = mergedTasks.reduce((sum2, m3) => sum2 + m3.similarity, 0) / mergedTasks.length;
101383
102354
  const compositeScore = mergeRatio * 0.6 + avgSimilarity * 0.4;
101384
102355
  const score = Math.round(compositeScore * 100);
101385
102356
  return {
@@ -101418,7 +102389,7 @@ class ConfidenceScorer {
101418
102389
  description: "No dependency patterns detected"
101419
102390
  };
101420
102391
  }
101421
- const avgConfidence = dependencyPatterns.reduce((sum, d2) => sum + d2.confidence, 0) / dependencyPatterns.length;
102392
+ const avgConfidence = dependencyPatterns.reduce((sum2, d2) => sum2 + d2.confidence, 0) / dependencyPatterns.length;
101422
102393
  const explicitCount = dependencyPatterns.filter((d2) => d2.source === "explicit").length;
101423
102394
  const explicitRatio = explicitCount / dependencyPatterns.length;
101424
102395
  const explicitBonus = explicitRatio * 10;
@@ -101440,8 +102411,8 @@ class ConfidenceScorer {
101440
102411
  description: "No conditional patterns detected"
101441
102412
  };
101442
102413
  }
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;
102414
+ const avgConfidence = conditionalPatterns.reduce((sum2, c4) => sum2 + c4.confidence, 0) / conditionalPatterns.length;
102415
+ const avgCoverage = conditionalPatterns.reduce((sum2, c4) => sum2 + (c4.totalStories > 0 ? c4.matchCount / c4.totalStories : 0), 0) / conditionalPatterns.length;
101445
102416
  const score = Math.round(Math.min(100, avgConfidence * 70 + avgCoverage * 30));
101446
102417
  return {
101447
102418
  name: "Condition Quality",
@@ -101451,7 +102422,7 @@ class ConfidenceScorer {
101451
102422
  };
101452
102423
  }
101453
102424
  calculateOverall(factors, sampleCount) {
101454
- const baseScore = factors.reduce((sum, f2) => sum + f2.score * f2.weight, 0);
102425
+ const baseScore = factors.reduce((sum2, f2) => sum2 + f2.score * f2.weight, 0);
101455
102426
  const extraWeightMap = {
101456
102427
  1: 0.5,
101457
102428
  2: 0.25,
@@ -101505,35 +102476,35 @@ class OutlierDetector {
101505
102476
  const sorted = [...values].sort((a3, b3) => a3 - b3);
101506
102477
  const n4 = sorted.length;
101507
102478
  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));
102479
+ const median2 = n4 % 2 === 1 ? sorted[midIndex] ?? 0 : ((sorted[midIndex - 1] ?? 0) + (sorted[midIndex] ?? 0)) / 2;
102480
+ const deviations = values.map((v2) => Math.abs(v2 - median2));
101510
102481
  const sortedDev = [...deviations].sort((a3, b3) => a3 - b3);
101511
102482
  const mad = n4 % 2 === 1 ? sortedDev[midIndex] ?? 0 : ((sortedDev[midIndex - 1] ?? 0) + (sortedDev[midIndex] ?? 0)) / 2;
101512
- return { median, mad };
102483
+ return { median: median2, mad };
101513
102484
  }
101514
- modifiedZScore(value, median, mad) {
102485
+ modifiedZScore(value, median2, mad) {
101515
102486
  if (mad === 0)
101516
102487
  return 0;
101517
- return this.madToStdDev * (value - median) / mad;
102488
+ return this.madToStdDev * (value - median2) / mad;
101518
102489
  }
101519
102490
  detectEstimationOutliers(analyses) {
101520
102491
  const estimations = analyses.map((a3) => ({
101521
102492
  storyId: a3.story.id,
101522
- estimation: a3.story.estimation ?? 0
102493
+ estimation: numericStoryEstimate(a3.story.estimation) ?? 0
101523
102494
  })).filter((e2) => e2.estimation > 0);
101524
102495
  if (estimations.length < 2)
101525
102496
  return [];
101526
102497
  const values = estimations.map((e2) => e2.estimation);
101527
- const { median, mad } = this.calculateMAD(values);
102498
+ const { median: median2, mad } = this.calculateMAD(values);
101528
102499
  if (mad === 0)
101529
102500
  return [];
101530
102501
  const outliers = [];
101531
102502
  for (const e2 of estimations) {
101532
- const zScore = this.modifiedZScore(e2.estimation, median, mad);
102503
+ const zScore = this.modifiedZScore(e2.estimation, median2, mad);
101533
102504
  const absZ = Math.abs(zScore);
101534
102505
  if (absZ > this.zThreshold) {
101535
- const expectedLower = median - this.zThreshold * mad / this.madToStdDev;
101536
- const expectedUpper = median + this.zThreshold * mad / this.madToStdDev;
102506
+ const expectedLower = median2 - this.zThreshold * mad / this.madToStdDev;
102507
+ const expectedUpper = median2 + this.zThreshold * mad / this.madToStdDev;
101537
102508
  outliers.push({
101538
102509
  type: "estimation",
101539
102510
  storyId: e2.storyId,
@@ -101557,16 +102528,16 @@ class OutlierDetector {
101557
102528
  if (counts.length < 2)
101558
102529
  return [];
101559
102530
  const values = counts.map((c4) => c4.count);
101560
- const { median, mad } = this.calculateMAD(values);
102531
+ const { median: median2, mad } = this.calculateMAD(values);
101561
102532
  if (mad === 0)
101562
102533
  return [];
101563
102534
  const outliers = [];
101564
102535
  for (const c4 of counts) {
101565
- const zScore = this.modifiedZScore(c4.count, median, mad);
102536
+ const zScore = this.modifiedZScore(c4.count, median2, mad);
101566
102537
  const absZ = Math.abs(zScore);
101567
102538
  if (absZ > this.zThreshold) {
101568
- const expectedLower = median - this.zThreshold * mad / this.madToStdDev;
101569
- const expectedUpper = median + this.zThreshold * mad / this.madToStdDev;
102539
+ const expectedLower = median2 - this.zThreshold * mad / this.madToStdDev;
102540
+ const expectedUpper = median2 + this.zThreshold * mad / this.madToStdDev;
101570
102541
  outliers.push({
101571
102542
  type: "task-count",
101572
102543
  storyId: c4.storyId,
@@ -101626,7 +102597,7 @@ class OutlierDetector {
101626
102597
 
101627
102598
  // ../atomize-core/src/services/template/learned-template-product.ts
101628
102599
  class LearnedTemplateProductBuilder {
101629
- buildMergedTemplate(analyses, mergedTasks) {
102600
+ buildMergedTemplate(analyses, mergedTasks, estimationSource) {
101630
102601
  const taskDefinitions = mergedTasks.map((mt) => {
101631
102602
  const task = { ...mt.task };
101632
102603
  if (mt.learnedDependsOn && mt.learnedDependsOn.length > 0) {
@@ -101647,8 +102618,6 @@ class LearnedTemplateProductBuilder {
101647
102618
  normalizeLearnedTaskPercentages(taskDefinitions);
101648
102619
  const workItemTypes = [...new Set(analyses.map((a3) => a3.story.type))];
101649
102620
  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
102621
  const storyIds = analyses.map((a3) => a3.story.id).join(", ");
101653
102622
  return {
101654
102623
  version: "1.0",
@@ -101662,12 +102631,7 @@ class LearnedTemplateProductBuilder {
101662
102631
  tags: allTags.length > 0 ? { include: allTags } : undefined
101663
102632
  },
101664
102633
  tasks: taskDefinitions,
101665
- estimation: {
101666
- strategy: "percentage",
101667
- rounding: "none",
101668
- minimumTaskPoints: 0,
101669
- defaultParentEstimation: avgEstimation
101670
- }
102634
+ estimation: learnedEstimationConfig(analyses.map((a3) => estimationSample(a3.story, a3.tasks)), estimationSource)
101671
102635
  };
101672
102636
  }
101673
102637
  buildEmptyTemplate(story) {
@@ -101688,11 +102652,10 @@ class LearnedTemplateProductBuilder {
101688
102652
  }
101689
102653
  };
101690
102654
  }
101691
- buildSingleStoryTemplate(story, tasks) {
101692
- const storyEstimation = story.estimation || 0;
102655
+ buildSingleStoryTemplate(story, tasks, estimationSource) {
102656
+ const shares = taskEstimateShares(tasks.map((task) => numericStoryEstimate(task.estimation)));
101693
102657
  const taskDefinitions = tasks.map((task, index) => {
101694
- const taskEstimation = task.estimation || 0;
101695
- const estimationPercent = storyEstimation > 0 ? Math.round(taskEstimation / storyEstimation * 100) : 0;
102658
+ const estimationPercent = shares[index] ?? 0;
101696
102659
  return {
101697
102660
  id: this.generateTaskId(task.title, index),
101698
102661
  title: this.extractTitlePattern(task.title, story.title),
@@ -101716,16 +102679,12 @@ class LearnedTemplateProductBuilder {
101716
102679
  tags: story.tags?.length ? { include: story.tags } : undefined
101717
102680
  },
101718
102681
  tasks: taskDefinitions,
101719
- estimation: {
101720
- strategy: "percentage",
101721
- rounding: "none",
101722
- minimumTaskPoints: 0,
101723
- defaultParentEstimation: storyEstimation
101724
- }
102682
+ estimation: learnedEstimationConfig([estimationSample(story, tasks)], estimationSource)
101725
102683
  };
101726
102684
  }
101727
102685
  generateSuggestions(analyses, patterns, confidence, outliers) {
101728
102686
  return [
102687
+ ...this.suggestEstimationChecks(analyses),
101729
102688
  ...this.suggestConfidenceImprovements(analyses, confidence),
101730
102689
  ...this.suggestOutlierRemovals(outliers),
101731
102690
  ...this.suggestNamingImprovements(patterns),
@@ -101734,7 +102693,18 @@ class LearnedTemplateProductBuilder {
101734
102693
  ...this.suggestFilterImprovements(patterns)
101735
102694
  ];
101736
102695
  }
101737
- generateVariations(analyses, patterns, mergedTasks) {
102696
+ suggestEstimationChecks(analyses) {
102697
+ const learned = inferEstimation(analyses.map((a3) => estimationSample(a3.story, a3.tasks)));
102698
+ const table = learned.conversion?.table;
102699
+ if (!table)
102700
+ return [];
102701
+ return learned.lowConfidenceValues.map((value) => ({
102702
+ type: "adjust-estimation",
102703
+ message: `The conversion for "${value}" (${table[value]}) was learned from a single Story; check it before relying on it.`,
102704
+ severity: "info"
102705
+ }));
102706
+ }
102707
+ generateVariations(analyses, patterns, mergedTasks, estimationSource) {
101738
102708
  const confidenceScorer = new ConfidenceScorer;
101739
102709
  const variations = [];
101740
102710
  const coreTasks = mergedTasks.filter((mt) => {
@@ -101742,7 +102712,7 @@ class LearnedTemplateProductBuilder {
101742
102712
  return storyCount / analyses.length >= 0.6;
101743
102713
  });
101744
102714
  if (coreTasks.length > 0 && coreTasks.length !== mergedTasks.length) {
101745
- const coreTemplate = this.buildMergedTemplate(analyses, coreTasks);
102715
+ const coreTemplate = this.buildMergedTemplate(analyses, coreTasks, estimationSource);
101746
102716
  coreTemplate.name = "Core Tasks Template";
101747
102717
  coreTemplate.description = "Tasks appearing in 60%+ of analyzed stories";
101748
102718
  variations.push({
@@ -101753,7 +102723,7 @@ class LearnedTemplateProductBuilder {
101753
102723
  });
101754
102724
  }
101755
102725
  if (mergedTasks.length > 0) {
101756
- const fullTemplate = this.buildMergedTemplate(analyses, mergedTasks);
102726
+ const fullTemplate = this.buildMergedTemplate(analyses, mergedTasks, estimationSource);
101757
102727
  fullTemplate.name = "Comprehensive Template";
101758
102728
  fullTemplate.description = "All tasks found across analyzed stories";
101759
102729
  variations.push({
@@ -101876,6 +102846,23 @@ class LearnedTemplateProductBuilder {
101876
102846
  return suggestions;
101877
102847
  }
101878
102848
  }
102849
+ function estimationSample(story, tasks) {
102850
+ return {
102851
+ storyEstimate: story.estimation,
102852
+ taskEstimates: tasks.map((task) => numericStoryEstimate(task.estimation))
102853
+ };
102854
+ }
102855
+ function learnedEstimationConfig(samples, source) {
102856
+ const learned = inferEstimation(samples);
102857
+ return {
102858
+ strategy: "percentage",
102859
+ rounding: "none",
102860
+ minimumTaskEstimate: 0,
102861
+ ...source ? { source } : {},
102862
+ ...learned.conversion ? { conversion: learned.conversion } : {},
102863
+ ...learned.defaultStoryEstimate !== undefined ? { defaultParentEstimation: learned.defaultStoryEstimate } : {}
102864
+ };
102865
+ }
101879
102866
 
101880
102867
  // ../atomize-core/src/services/template/task-merger.ts
101881
102868
  class TaskMerger {
@@ -101969,15 +102956,15 @@ class TaskMerger {
101969
102956
  for (const value of values) {
101970
102957
  counts.set(value, (counts.get(value) ?? 0) + 1);
101971
102958
  }
101972
- let mostCommon = values[0] ?? 0;
102959
+ let mostCommon2 = values[0] ?? 0;
101973
102960
  let maxCount = 0;
101974
102961
  for (const [value, count] of counts) {
101975
- if (count > maxCount || count === maxCount && value > mostCommon) {
101976
- mostCommon = value;
102962
+ if (count > maxCount || count === maxCount && value > mostCommon2) {
102963
+ mostCommon2 = value;
101977
102964
  maxCount = count;
101978
102965
  }
101979
102966
  }
101980
- return mostCommon;
102967
+ return mostCommon2;
101981
102968
  }
101982
102969
  averagePriority(values) {
101983
102970
  if (values.length === 0)
@@ -102082,27 +103069,28 @@ class StoryLearner {
102082
103069
  this.learningSession = learningSession;
102083
103070
  this.productBuilder = productBuilder;
102084
103071
  }
102085
- async learnFromStory(storyId) {
103072
+ async learnFromStory(storyId, options = {}) {
102086
103073
  logger.info(`Learning template from story: ${storyId}`);
102087
- const story = await this.platform.getWorkItem(storyId);
102088
- if (!story) {
103074
+ const fetched = await this.platform.getWorkItem(storyId);
103075
+ if (!fetched) {
102089
103076
  throw new TemplateGenerationError(`Story ${storyId} not found`);
102090
103077
  }
103078
+ const story = withEstimationSource(fetched, options.estimationSource);
102091
103079
  const tasks = await this.platform.getChildren(storyId);
102092
103080
  if (!tasks || tasks.length === 0) {
102093
103081
  throw new TemplateGenerationError(`Story ${storyId} has no child tasks to learn from`);
102094
103082
  }
102095
103083
  logger.info(`Found ${tasks.length} tasks to analyze`);
102096
- const template = this.productBuilder.buildSingleStoryTemplate(story, tasks);
103084
+ const template = this.productBuilder.buildSingleStoryTemplate(story, tasks, options.estimationSource);
102097
103085
  logger.info("Template learned successfully");
102098
103086
  return template;
102099
103087
  }
102100
- async learnFromStories(storyIds) {
103088
+ async learnFromStories(storyIds, options = {}) {
102101
103089
  logger.info(`Learning template from ${storyIds.length} stories: ${storyIds.join(", ")}`);
102102
103090
  if (storyIds.length === 0) {
102103
103091
  throw new TemplateGenerationError("No story IDs provided");
102104
103092
  }
102105
- const results = await Promise.allSettled(storyIds.map((id) => this.analyzeStory(id)));
103093
+ const results = await Promise.allSettled(storyIds.map((id) => this.analyzeStory(id, options.estimationSource)));
102106
103094
  const analyses = [];
102107
103095
  const skipped = [];
102108
103096
  for (let i3 = 0;i3 < results.length; i3++) {
@@ -102134,9 +103122,9 @@ class StoryLearner {
102134
103122
  confidence,
102135
103123
  outliers
102136
103124
  } = this.learningSession.run(analyses);
102137
- const template = this.productBuilder.buildMergedTemplate(analyses, mergedTasks);
103125
+ const template = this.productBuilder.buildMergedTemplate(analyses, mergedTasks, options.estimationSource);
102138
103126
  const suggestions = this.productBuilder.generateSuggestions(analyses, patterns, confidence, outliers);
102139
- const variations = this.productBuilder.generateVariations(analyses, patterns, mergedTasks);
103127
+ const variations = this.productBuilder.generateVariations(analyses, patterns, mergedTasks, options.estimationSource);
102140
103128
  logger.info(`Multi-story learning complete: ${analyses.length} analyzed, ${skipped.length} skipped, confidence: ${confidence.level} (${confidence.overall}%)`);
102141
103129
  return {
102142
103130
  analyses,
@@ -102149,11 +103137,12 @@ class StoryLearner {
102149
103137
  outliers
102150
103138
  };
102151
103139
  }
102152
- async analyzeStory(storyId) {
102153
- const story = await this.platform.getWorkItem(storyId);
102154
- if (!story) {
103140
+ async analyzeStory(storyId, estimationSource) {
103141
+ const fetched = await this.platform.getWorkItem(storyId);
103142
+ if (!fetched) {
102155
103143
  throw new TemplateGenerationError(`Story ${storyId} not found`);
102156
103144
  }
103145
+ const story = withEstimationSource(fetched, estimationSource);
102157
103146
  const tasks = await this.platform.getChildren(storyId);
102158
103147
  const warnings = [];
102159
103148
  if (tasks.length === 0) {
@@ -102168,7 +103157,7 @@ class StoryLearner {
102168
103157
  if (tasks.length === 1) {
102169
103158
  warnings.push(`Story ${storyId} has only 1 task, which may not be representative`);
102170
103159
  }
102171
- const template = this.productBuilder.buildSingleStoryTemplate(story, tasks);
103160
+ const template = this.productBuilder.buildSingleStoryTemplate(story, tasks, estimationSource);
102172
103161
  return { story, tasks, template, warnings };
102173
103162
  }
102174
103163
  extractTitlePattern(taskTitle, storyTitle) {
@@ -102181,6 +103170,11 @@ class StoryLearner {
102181
103170
  return this.productBuilder.detectActivity(title, description);
102182
103171
  }
102183
103172
  }
103173
+ function withEstimationSource(story, source) {
103174
+ if (!source)
103175
+ return story;
103176
+ return { ...story, estimation: readStoryEstimate(story, { kind: "override", field: source }) };
103177
+ }
102184
103178
 
102185
103179
  // src/cli/commands/template/template-create.command.ts
102186
103180
  init_template_library();
@@ -102576,6 +103570,8 @@ mixins: string[] # optional — list of mixin references or file paths (e
102576
103570
  # Mixins contribute tasks only. Child tasks override mixin tasks with the same id.
102577
103571
  # Template names are NOT valid mixin sources.
102578
103572
 
103573
+ taskType: string # optional — work item type Tasks are created as (e.g. "Task", "Sub-task"). Omit to use the platform default.
103574
+
102579
103575
  filter: # required (unless inherited via extends)
102580
103576
  workItemTypes: string[] # e.g. ["User Story"], ["Bug"]
102581
103577
  states: string[] # e.g. ["New", "Active", "Approved"] — items must be in one of these states
@@ -102606,6 +103602,7 @@ tasks: # required, minimum 1 item
102606
103602
  tags: string[] # optional
102607
103603
  id: string # optional, required if this task is referenced in dependsOn
102608
103604
  dependsOn: string[] # optional, references other task ids
103605
+ repeat: number # optional, default 1 — create this many identical Tasks from this one definition (see rule 9)
102609
103606
  condition: # optional — skip this task entirely when condition is false
102610
103607
  # Standard fields (case-sensitive, always lowercase camelCase — NOT the ADO REST field
102611
103608
  # names like "System.State" or "Microsoft.VSTS.Common.Priority"):
@@ -102627,11 +103624,21 @@ tasks: # required, minimum 1 item
102627
103624
  estimation: # optional
102628
103625
  strategy: "percentage"
102629
103626
  rounding: "nearest" | "up" | "down" | "none" # default "none" — omit unless the user asks for rounding
102630
- minimumTaskPoints: number # optional, default 0
103627
+ normalize: "auto" | "always" | "never" # optional, default "auto" — set "never" only if the user wants percentages used exactly as written
103628
+ minimumTaskEstimate: number # optional, default 0 — minimum per task, in the Task's unit (hours on Azure DevOps)
103629
+ 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).
103630
+ conversion: # optional — Story Estimate → Task-unit total before the split. Set exactly ONE of factor or table.
103631
+ factor: number # e.g. 4 when 1 point = 4 hours
103632
+ table: # exact value → amount, e.g. { S: 2, M: 4, L: 5, XL: 13 }
103633
+ multipliers: boolean # optional, only with a table of size keys: "0.3XL" = 0.3 × XL
103634
+ targetFields: string[] # optional — Task fields that receive the estimate, replacing the platform defaults. Rarely needed.
103635
+ ifParentHasNoEstimation: "warn" | "skip" | "use-default" # optional, default "warn" (tasks get blank estimates)
103636
+ defaultParentEstimation: number | string # optional — Story Estimate to assume with use-default, e.g. 5 or "M"
102631
103637
 
102632
103638
  validation: # optional
102633
- minTasks: number
103639
+ minTasks: number # each copy of a repeated task counts
102634
103640
  maxTasks: number
103641
+ maxRepeat: number # optional, default 20 — the highest repeat any task may use
102635
103642
  totalEstimationMustBe: number # usually 100
102636
103643
  totalEstimationRange:
102637
103644
  min: number
@@ -102646,7 +103653,7 @@ metadata: # optional
102646
103653
 
102647
103654
  CONSTRAINTS (you must follow these):
102648
103655
  1. Estimation sums:
102649
- - Simple templates (no task-level conditions): estimationPercent values MUST sum to exactly 100.
103656
+ - Simple templates (no task-level conditions): estimationPercent values MUST sum to exactly 100. A repeated task contributes its estimationPercent once per copy.
102650
103657
  - Multi-archetype templates (tasks use conditions to target different story types): each archetype's active task set should sum to ~100% independently. The total across all declared tasks will naturally exceed 100 — this is expected and valid.
102651
103658
  2. If a task uses dependsOn, all referenced IDs must exist on other tasks in the same template.
102652
103659
  3. workItemTypes values are case-sensitive strings matching ADO work item types.
@@ -102672,6 +103679,17 @@ CONSTRAINTS (you must follow these):
102672
103679
  estimationPercentCondition:
102673
103680
  - condition: { field: priority, operator: lte, value: 2 }
102674
103681
  percent: 35 # override for critical bugs (priority 1 or 2)
103682
+ 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.
103683
+ - Use field reference names and work item types exactly as given in the workspace context; never invent them.
103684
+ - With a conversion table on a picklist source, use the picklist's allowed values exactly as keys and cover every one.
103685
+ - For a numeric scale where hours grow faster than points, use a table with numeric keys instead of a factor.
103686
+ - Conditions on estimation compare the raw Story Estimate: with size categories use equals/not-equals (e.g. value: "L"), never gt/lt/gte/lte.
103687
+ 9. REPEATED TASKS — when the user wants several identical Tasks from the same definition (e.g. "one review task per developer, 4 developers"), write ONE task with repeat: N instead of copying the task block N times.
103688
+ - Atomize titles the copies "<title> (1)", "<title> (2)", ... so do not number the title yourself.
103689
+ - Each copy gets the task's full estimationPercent, so every copy counts toward the total: repeat: 4 at 10% contributes 40%.
103690
+ - NEVER combine repeat with dependsOn, and never list a repeated task's id in another task's dependsOn.
103691
+ - repeat must be a whole number, at most 20 unless you also set validation.maxRepeat to at least that number. Only raise maxRepeat when the user explicitly asks for more than 20 copies.
103692
+ - Every copy is identical; when copies need different assignees, estimates or fields, write separate tasks instead.
102675
103693
 
102676
103694
  EXAMPLES:
102677
103695
 
@@ -102875,7 +103893,32 @@ filter:
102875
103893
  tasks:
102876
103894
  - id: "implement"
102877
103895
  title: "Implement (Mobile): \${story.title}"
102878
- estimationPercent: 60`;
103896
+ estimationPercent: 60
103897
+
103898
+ Example 7 — Repeated identical tasks (one review per developer):
103899
+ version: "1.0"
103900
+ name: "Team Review"
103901
+ description: "Implementation plus one review conclusions task for each of the four developers."
103902
+ filter:
103903
+ workItemTypes: ["User Story"]
103904
+ states: ["Active"]
103905
+ excludeIfHasTasks: true
103906
+ tasks:
103907
+ - title: "Implementation"
103908
+ estimationPercent: 60
103909
+ activity: "Development"
103910
+ assignTo: "@ParentAssignee"
103911
+ - title: "Add review conclusions"
103912
+ estimationPercent: 10
103913
+ repeat: 4
103914
+ activity: "Development"
103915
+ assignTo: "@Unassigned"
103916
+ estimation:
103917
+ strategy: "percentage"
103918
+ metadata:
103919
+ category: "Agile"
103920
+ difficulty: "beginner"
103921
+ estimationGuidelines: "60% + 4 copies × 10% = 100%."`;
102879
103922
  function buildSystemPrompt() {
102880
103923
  return SYSTEM_PROMPT;
102881
103924
  }
@@ -102890,7 +103933,7 @@ ${description}`;
102890
103933
  Observed patterns from this user's Azure DevOps workspace:
102891
103934
  ${groundingContext}
102892
103935
 
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.
103936
+ 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
103937
  ---`;
102895
103938
  }
102896
103939
  if (prevErrors && prevErrors.length > 0) {
@@ -102944,6 +103987,7 @@ init_prompt_utilities();
102944
103987
 
102945
103988
  // src/cli/commands/template/template-wizard.ts
102946
103989
  init_dist6();
103990
+ init_schema2();
102947
103991
  init_errors();
102948
103992
  init_estimation_normalizer();
102949
103993
  init_source();
@@ -103189,7 +104233,7 @@ var STORY_FIELDS = [
103189
104233
  { label: "Tags (array)", value: "tags" },
103190
104234
  { label: "Title", value: "title" },
103191
104235
  { label: "State", value: "state" },
103192
- { label: "Estimation (points)", value: "estimation" },
104236
+ { label: "Story Estimate (raw value, e.g. points or size)", value: "estimation" },
103193
104237
  { label: "Priority", value: "priority" },
103194
104238
  { label: "Description", value: "description" },
103195
104239
  { label: "Assigned To", value: "assignedTo" },
@@ -103438,12 +104482,22 @@ async function configureTaskTags(defaults) {
103438
104482
  return feature.data.length > 0 ? feature.data : undefined;
103439
104483
  }
103440
104484
  async function configureAdvancedTaskOptions(storyFieldSchemas, defaults) {
103441
- const dependsOnRaw = assertNotCancelled(await text({
103442
- message: "Depends on task IDs (comma-separated, optional):",
103443
- placeholder: "e.g. task-setup, task-db, task-build, task-test",
103444
- initialValue: defaults?.dependsOn?.join(", ") ?? ""
104485
+ const repeatRaw = assertNotCancelled(await text({
104486
+ message: "How many identical Tasks should this create?",
104487
+ placeholder: "1",
104488
+ initialValue: String(defaults?.repeat ?? 1),
104489
+ validate: Validators.positiveInteger("Number of Tasks")
103445
104490
  }));
103446
- const dependsOn = Filters.commaSeparated(dependsOnRaw);
104491
+ const repeat = Number(repeatRaw);
104492
+ let dependsOn = [];
104493
+ if (repeat === 1) {
104494
+ const dependsOnRaw = assertNotCancelled(await text({
104495
+ message: "Depends on task IDs (comma-separated, optional):",
104496
+ placeholder: "e.g. task-setup, task-db, task-build, task-test",
104497
+ initialValue: defaults?.dependsOn?.join(", ") ?? ""
104498
+ }));
104499
+ dependsOn = Filters.commaSeparated(dependsOnRaw);
104500
+ }
103447
104501
  const addCondition = assertNotCancelled(await confirm({ message: "Add a task condition?", initialValue: false }));
103448
104502
  let condition;
103449
104503
  if (addCondition) {
@@ -103456,6 +104510,8 @@ async function configureAdvancedTaskOptions(storyFieldSchemas, defaults) {
103456
104510
  validate: Validators.priorityRange
103457
104511
  }));
103458
104512
  const result = {};
104513
+ if (repeat > 1)
104514
+ result.repeat = repeat;
103459
104515
  if (dependsOn.length > 0)
103460
104516
  result.dependsOn = dependsOn;
103461
104517
  if (condition)
@@ -103499,6 +104555,8 @@ async function buildTaskDefinition(isFirstTask, includeAdvanced, fieldSchemas, s
103499
104555
  }
103500
104556
  const advanced = await configureAdvancedTaskOptions(storyFieldSchemas, defaults);
103501
104557
  Object.assign(taskDef, advanced);
104558
+ } else if (defaults?.repeat !== undefined) {
104559
+ taskDef.repeat = defaults.repeat;
103502
104560
  }
103503
104561
  const customFields = await configureCustomFields(fieldSchemas, storyFieldSchemas);
103504
104562
  if (Object.keys(customFields).length > 0) {
@@ -103527,6 +104585,7 @@ function normalizeEstimations(tasks) {
103527
104585
  }
103528
104586
  }
103529
104587
  async function previewTemplate(template, ctx) {
104588
+ await offerRepeatLimit(template);
103530
104589
  displayTemplatePreview(template);
103531
104590
  const action = assertNotCancelled(await select({
103532
104591
  message: "What would you like to do?",
@@ -103539,6 +104598,21 @@ async function previewTemplate(template, ctx) {
103539
104598
  }));
103540
104599
  return await handlePreviewAction(action, template, ctx);
103541
104600
  }
104601
+ async function offerRepeatLimit(template) {
104602
+ const highest = Math.max(...template.tasks.map(copyCount));
104603
+ const limit = template.validation?.maxRepeat ?? DEFAULT_MAX_REPEAT;
104604
+ if (highest <= limit)
104605
+ return;
104606
+ const raise = assertNotCancelled(await confirm({
104607
+ message: `A task creates ${highest} copies, above the limit of ${limit}. Raise the limit (validation.maxRepeat) to ${highest}?`,
104608
+ initialValue: true
104609
+ }));
104610
+ if (raise)
104611
+ template.validation = { ...template.validation, maxRepeat: highest };
104612
+ }
104613
+ function totalTaskPercent(tasks) {
104614
+ return tasks.reduce((sum2, task) => sum2 + (task.estimationPercent || 0) * copyCount(task), 0);
104615
+ }
103542
104616
  function displayTemplatePreview(template) {
103543
104617
  output4.print(source_default.cyan(`
103544
104618
  Template Preview
@@ -103579,13 +104653,16 @@ Filter Configuration:`));
103579
104653
  }
103580
104654
  }
103581
104655
  function displayTasksSummary(template) {
104656
+ const generated = template.tasks.reduce((sum2, task) => sum2 + copyCount(task), 0);
104657
+ const counted = generated === template.tasks.length ? "" : `, ${generated} generated`;
103582
104658
  output4.print(source_default.bold(`
103583
- Tasks (${template.tasks.length}):`));
103584
- const totalEstimation = template.tasks.reduce((sum, task) => sum + (task.estimationPercent || 0), 0);
104659
+ Tasks (${template.tasks.length}${counted}):`));
104660
+ const totalEstimation = totalTaskPercent(template.tasks);
103585
104661
  template.tasks.forEach((task, index) => {
103586
104662
  const percent = task.estimationPercent || 0;
103587
104663
  const bar = "■".repeat(Math.round(percent / 5));
103588
- output4.print(` ${index + 1}. ${task.title} ${source_default.gray(`[${percent}%]`)} ${source_default.green(bar)}`);
104664
+ const copies = copyCount(task) > 1 ? source_default.cyan(` ×${copyCount(task)}`) : "";
104665
+ output4.print(` ${index + 1}. ${task.title}${copies} ${source_default.gray(`[${percent}%]`)} ${source_default.green(bar)}`);
103589
104666
  if (task.description) {
103590
104667
  output4.print(source_default.gray(` ${task.description}`));
103591
104668
  }
@@ -103615,6 +104692,9 @@ Validation Rules:`));
103615
104692
  if (template.validation.minTasks || template.validation.maxTasks) {
103616
104693
  output4.print(` Task count: ${template.validation.minTasks || "no min"} - ${template.validation.maxTasks || "no max"}`);
103617
104694
  }
104695
+ if (template.validation.maxRepeat !== undefined) {
104696
+ output4.print(` Copies per task: up to ${template.validation.maxRepeat}`);
104697
+ }
103618
104698
  }
103619
104699
  function displayMetadata(template) {
103620
104700
  if (!template.metadata)
@@ -103679,8 +104759,9 @@ Editing Tasks
103679
104759
  Editing Estimation Settings
103680
104760
  `));
103681
104761
  const { configureEstimation: configureEstimation2 } = await Promise.resolve().then(() => (init_template_wizard_helper_command(), exports_template_wizard_helper_command));
103682
- const estimation = await configureEstimation2(template.estimation);
104762
+ const { estimation, taskType } = await configureEstimation2(template.estimation, ctx.estimationCtx ?? { storyFields: ctx.storyFieldSchemas, workItemTypes: ctx.filterCtx.workItemTypes }, template.taskType);
103683
104763
  template.estimation = estimation;
104764
+ template.taskType = taskType;
103684
104765
  }).with("validation", async () => {
103685
104766
  output4.print(source_default.cyan(`
103686
104767
  Editing Validation Rules
@@ -103745,7 +104826,7 @@ function showStepHint(stepName) {
103745
104826
  const hints = {
103746
104827
  filter: "Tip: Use filters to select which work items this template applies to",
103747
104828
  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",
104829
+ estimation: "Tip: Choose how Story Estimates turn into Task hours and where they are written",
103749
104830
  validation: "Tip: Add constraints to ensure templates are used correctly",
103750
104831
  metadata: "Tip: Metadata helps others understand when to use this template"
103751
104832
  };
@@ -103815,13 +104896,17 @@ async function shouldAddMoreTasks(taskCounter) {
103815
104896
  }));
103816
104897
  }
103817
104898
  async function handleEstimationNormalization(tasks) {
103818
- const totalEstimation = tasks.reduce((sum, task) => sum + (task.estimationPercent || 0), 0);
104899
+ const totalEstimation = totalTaskPercent(tasks);
103819
104900
  if (totalEstimation === 100) {
103820
104901
  output4.print(source_default.green("✓ Total estimation is 100%"));
103821
104902
  return;
103822
104903
  }
103823
104904
  output4.print(source_default.yellow(`
103824
104905
  Warning: Total estimation is ${totalEstimation}% (should be 100%)`));
104906
+ if (tasks.some((task) => copyCount(task) > 1)) {
104907
+ output4.print(source_default.yellow(" Each copy of a repeated task counts toward the total; adjust the percentages to reach 100%."));
104908
+ return;
104909
+ }
103825
104910
  const shouldNormalize = await promptForNormalization();
103826
104911
  if (shouldNormalize) {
103827
104912
  normalizeEstimations(tasks);
@@ -103859,7 +104944,7 @@ async function editTasksInteractively(tasks, fieldSchemas, storyFieldSchemas) {
103859
104944
  Tasks (${currentTasks.length}) — total: ${totalLabel}
103860
104945
  `));
103861
104946
  currentTasks.forEach((t3, i3) => {
103862
- output4.print(source_default.gray(` ${i3 + 1}. ${t3.title} [${t3.estimationPercent ?? 0}%]`));
104947
+ output4.print(source_default.gray(` ${i3 + 1}. ${t3.title}${copyCount(t3) > 1 ? ` ×${copyCount(t3)}` : ""} [${t3.estimationPercent ?? 0}%]`));
103863
104948
  });
103864
104949
  output4.blankLine();
103865
104950
  };
@@ -103881,7 +104966,7 @@ Tasks (${currentTasks.length}) — total: ${totalLabel}
103881
104966
  const idx = assertNotCancelled(await select({
103882
104967
  message: "Select task to edit:",
103883
104968
  options: currentTasks.map((t3, i3) => ({
103884
- label: `${i3 + 1}. ${t3.title} [${t3.estimationPercent ?? 0}%]`,
104969
+ label: `${i3 + 1}. ${t3.title}${copyCount(t3) > 1 ? ` ×${copyCount(t3)}` : ""} [${t3.estimationPercent ?? 0}%]`,
103885
104970
  value: i3
103886
104971
  }))
103887
104972
  }));
@@ -103898,7 +104983,7 @@ Tasks (${currentTasks.length}) — total: ${totalLabel}
103898
104983
  const idx = assertNotCancelled(await select({
103899
104984
  message: "Select task to remove:",
103900
104985
  options: currentTasks.map((t3, i3) => ({
103901
- label: `${i3 + 1}. ${t3.title} [${t3.estimationPercent ?? 0}%]`,
104986
+ label: `${i3 + 1}. ${t3.title}${copyCount(t3) > 1 ? ` ×${copyCount(t3)}` : ""} [${t3.estimationPercent ?? 0}%]`,
103902
104987
  value: i3
103903
104988
  }))
103904
104989
  }));
@@ -103943,6 +105028,7 @@ Customize Template
103943
105028
  const adapter = await createAzureDevOpsAdapter(profile);
103944
105029
  const metadataReader = requireProjectMetadataReader(adapter);
103945
105030
  const savedQueryReader = requireSavedQueryReader(adapter);
105031
+ const estimationDefaults2 = adapter.getEstimationDefaults();
103946
105032
  const [
103947
105033
  taskSchemas,
103948
105034
  liveWorkItemTypes,
@@ -103951,7 +105037,7 @@ Customize Template
103951
105037
  liveTeams,
103952
105038
  liveSavedQueries
103953
105039
  ] = await Promise.all([
103954
- metadataReader.getFieldSchemas("Task"),
105040
+ metadataReader.getFieldSchemas(template.taskType ?? estimationDefaults2.taskWorkItemType),
103955
105041
  metadataReader.getWorkItemTypes(),
103956
105042
  metadataReader.getAreaPaths(),
103957
105043
  metadataReader.getIterationPaths(),
@@ -103960,6 +105046,7 @@ Customize Template
103960
105046
  ]);
103961
105047
  return {
103962
105048
  metadataReader,
105049
+ estimationDefaults: estimationDefaults2,
103963
105050
  fieldSchemas: taskSchemas,
103964
105051
  filterCtx: {
103965
105052
  workItemTypes: liveWorkItemTypes,
@@ -104007,6 +105094,7 @@ Editing Name & Description
104007
105094
  let filterCtx;
104008
105095
  let fieldSchemas;
104009
105096
  let adapterForWizard;
105097
+ let estimationDefaults;
104010
105098
  try {
104011
105099
  const conn = await connectionPromise;
104012
105100
  if (!wasAlreadyConnected)
@@ -104014,6 +105102,7 @@ Editing Name & Description
104014
105102
  filterCtx = conn.filterCtx;
104015
105103
  fieldSchemas = conn.fieldSchemas;
104016
105104
  adapterForWizard = conn.metadataReader;
105105
+ estimationDefaults = conn.estimationDefaults;
104017
105106
  } catch (err) {
104018
105107
  if (!wasAlreadyConnected)
104019
105108
  connectSpinner.stop("Connection failed");
@@ -104025,6 +105114,12 @@ Editing Name & Description
104025
105114
  }
104026
105115
  let storyFieldSchemas = [];
104027
105116
  let storySchemasFetched = false;
105117
+ const estimationContext = async () => ({
105118
+ storyFields: storyFieldSchemas.length ? storyFieldSchemas : await adapterForWizard.getFieldSchemas(),
105119
+ workItemTypes: filterCtx.workItemTypes,
105120
+ defaults: estimationDefaults,
105121
+ getTaskFields: (type) => adapterForWizard.getFieldSchemas(type)
105122
+ });
104028
105123
  for (const section of ["filter", "tasks", "estimation", "validation", "metadata"]) {
104029
105124
  if (!sections.includes(section))
104030
105125
  continue;
@@ -104062,7 +105157,14 @@ Editing Tasks
104062
105157
  output5.print(source_default.cyan(`
104063
105158
  Editing Estimation Settings
104064
105159
  `));
104065
- template.estimation = await configureEstimation(template.estimation);
105160
+ if (!storySchemasFetched) {
105161
+ const wit = template.filter.workItemTypes?.[0];
105162
+ storyFieldSchemas = wit ? await adapterForWizard.getFieldSchemas(wit) : [];
105163
+ storySchemasFetched = true;
105164
+ }
105165
+ const { estimation, taskType } = await configureEstimation(template.estimation, await estimationContext(), template.taskType);
105166
+ template.estimation = estimation;
105167
+ template.taskType = taskType;
104066
105168
  break;
104067
105169
  }
104068
105170
  case "validation": {
@@ -104103,7 +105205,8 @@ Editing Metadata
104103
105205
  filterCtx,
104104
105206
  fieldSchemas,
104105
105207
  storyFieldSchemas,
104106
- workItemType: template.filter.workItemTypes?.[0]
105208
+ workItemType: template.filter.workItemTypes?.[0],
105209
+ estimationCtx: await estimationContext()
104107
105210
  };
104108
105211
  const confirmed = await previewTemplate(template, wizardCtx);
104109
105212
  if (!confirmed) {
@@ -104421,7 +105524,7 @@ async function handlePreviewLoop(template, options) {
104421
105524
  // src/cli/commands/template/template-create.command.ts
104422
105525
  init_template_wizard_helper_command();
104423
105526
  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) => {
105527
+ 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
105528
  const output9 = createCommandOutput(resolveCommandOutputPolicy({ quiet: options.quiet, verbose: false }));
104426
105529
  try {
104427
105530
  output9.intro(" Atomize — Template Creator");
@@ -104680,7 +105783,7 @@ async function createFromStories(options) {
104680
105783
  const learner = new StoryLearner(learningPlatform);
104681
105784
  const learnSpinner = createManagedSpinner();
104682
105785
  learnSpinner.start(`Learning from ${storyIds.length} stories...`);
104683
- const result = await learner.learnFromStories(storyIds);
105786
+ const result = await learner.learnFromStories(storyIds, { estimationSource: options.estimationSource });
104684
105787
  learnSpinner.stop(`Analyzed ${result.analyses.length} stories ✓`);
104685
105788
  displayMultiStoryResults(result);
104686
105789
  if (result.variations.length > 1) {
@@ -104776,8 +105879,9 @@ Create Template from Scratch
104776
105879
  const adapter = await createAzureDevOpsAdapter(profile);
104777
105880
  const metadataReader = requireProjectMetadataReader(adapter);
104778
105881
  const savedQueryReader = requireSavedQueryReader(adapter);
105882
+ const estimationDefaults2 = adapter.getEstimationDefaults();
104779
105883
  const [taskSchemas, liveWorkItemTypes, liveAreaPaths, liveIterationPaths, liveTeams, liveSavedQueries] = await Promise.all([
104780
- metadataReader.getFieldSchemas("Task"),
105884
+ metadataReader.getFieldSchemas(estimationDefaults2.taskWorkItemType),
104781
105885
  metadataReader.getWorkItemTypes(),
104782
105886
  metadataReader.getAreaPaths(),
104783
105887
  metadataReader.getIterationPaths(),
@@ -104786,6 +105890,7 @@ Create Template from Scratch
104786
105890
  ]);
104787
105891
  return {
104788
105892
  metadataReader,
105893
+ estimationDefaults: estimationDefaults2,
104789
105894
  fieldSchemas: taskSchemas,
104790
105895
  filterCtx: {
104791
105896
  workItemTypes: liveWorkItemTypes,
@@ -104812,6 +105917,7 @@ Create Template from Scratch
104812
105917
  let filterCtx;
104813
105918
  let fieldSchemas;
104814
105919
  let adapterForWizard;
105920
+ let estimationDefaults;
104815
105921
  const wasAlreadyConnected = connectionSettled;
104816
105922
  const connectSpinner = createManagedSpinner();
104817
105923
  if (!wasAlreadyConnected)
@@ -104823,6 +105929,7 @@ Create Template from Scratch
104823
105929
  filterCtx = conn.filterCtx;
104824
105930
  fieldSchemas = conn.fieldSchemas;
104825
105931
  adapterForWizard = conn.metadataReader;
105932
+ estimationDefaults = conn.estimationDefaults;
104826
105933
  } catch (err) {
104827
105934
  if (!wasAlreadyConnected)
104828
105935
  connectSpinner.stop("Connection failed");
@@ -104870,9 +105977,15 @@ Create Template from Scratch
104870
105977
  printStep(source_default.blue(`
104871
105978
  [${currentStep}/${totalSteps}] Estimation Settings`));
104872
105979
  printStep(source_default.gray("████░░"));
104873
- printStep(source_default.gray(`Tip: Choose how story points will be calculated and rounded
105980
+ printStep(source_default.gray(`Tip: Choose how Story Estimates turn into Task hours and where they are written
104874
105981
  `));
104875
- const estimation = await configureEstimation();
105982
+ const estimationCtx = {
105983
+ storyFields: storyFieldSchemas.length ? storyFieldSchemas : await adapterForWizard.getFieldSchemas(),
105984
+ workItemTypes: filterCtx.workItemTypes,
105985
+ defaults: estimationDefaults,
105986
+ getTaskFields: (type) => adapterForWizard.getFieldSchemas(type)
105987
+ };
105988
+ const { estimation, taskType } = await configureEstimation(undefined, estimationCtx);
104876
105989
  currentStep++;
104877
105990
  printStep(source_default.blue(`
104878
105991
  [${currentStep}/${totalSteps}] Validation Rules (Optional)`));
@@ -104912,6 +106025,7 @@ Create Template from Scratch
104912
106025
  created: new Date().toISOString(),
104913
106026
  filter: filterConfig,
104914
106027
  tasks,
106028
+ ...taskType ? { taskType } : {},
104915
106029
  estimation,
104916
106030
  validation,
104917
106031
  metadata
@@ -104925,7 +106039,8 @@ Create Template from Scratch
104925
106039
  filterCtx,
104926
106040
  fieldSchemas,
104927
106041
  storyFieldSchemas,
104928
- workItemType
106042
+ workItemType,
106043
+ estimationCtx
104929
106044
  };
104930
106045
  const confirmed = await previewTemplate(template, wizardCtx);
104931
106046
  if (!confirmed) {
@@ -105568,6 +106683,7 @@ init_dist6();
105568
106683
  init_logger2();
105569
106684
  init_estimation_distribution();
105570
106685
  init_custom_field_verifier();
106686
+ init_schema2();
105571
106687
  init_template_library();
105572
106688
  init_errors();
105573
106689
  init_source();
@@ -105711,7 +106827,9 @@ function createValidateCommandDeps(depsOptions) {
105711
106827
  strict: options.strict === true,
105712
106828
  platform: {
105713
106829
  getFieldSchemas: (workItemType) => metadataReader.getFieldSchemas(workItemType),
105714
- listSavedQueries: (folder) => savedQueryReader.listSavedQueries(folder)
106830
+ listSavedQueries: (folder) => savedQueryReader.listSavedQueries(folder),
106831
+ getEstimationDefaults: () => adapter.getEstimationDefaults(),
106832
+ getWorkItemTypes: adapter.getWorkItemTypes?.bind(adapter)
105715
106833
  }
105716
106834
  },
105717
106835
  connectionWarnings: []
@@ -105800,7 +106918,7 @@ function getTemplateSummary(template) {
105800
106918
  const totalPercent = totalUnconditionalEstimationPercent(template.tasks);
105801
106919
  return {
105802
106920
  name: template.name,
105803
- tasks: template.tasks.length,
106921
+ tasks: generatedTaskCount(template.tasks),
105804
106922
  totalEstimation: `${totalPercent}%`
105805
106923
  };
105806
106924
  }