@sppg2001/atomize 4.1.0 → 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.
Files changed (2) hide show
  1. package/dist/cli/index.js +221 -46
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -178,6 +178,11 @@ 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
+
181
186
  // ../atomize-schema/src/estimation-fields.ts
182
187
  function matchesFieldPattern(referenceName, patterns = []) {
183
188
  return patterns.some((pattern) => pattern.endsWith(".*") ? referenceName.startsWith(pattern.slice(0, -1)) : referenceName === pattern);
@@ -20112,23 +20117,63 @@ function ownPercentRange(task) {
20112
20117
  const candidates = [task.estimationPercent ?? 0, ...task.estimationPercentCondition?.map((rule) => rule.percent) ?? []];
20113
20118
  return [Math.min(...candidates), Math.max(...candidates)];
20114
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
+ }
20115
20157
  function validateTaskConstraints(v, tasks, ctx) {
20116
- if (v?.minTasks !== undefined && tasks.length < v.minTasks) {
20117
- 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;
20118
20163
  ctx.addIssue({
20119
20164
  code: "custom",
20120
20165
  path: ["tasks"],
20121
- message: `Template has ${tasks.length} task(s), but minimum is ${v.minTasks}. Add ${needed} more task(s).`,
20122
- 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 }
20123
20168
  });
20124
20169
  }
20125
- if (v?.maxTasks !== undefined && tasks.length > v.maxTasks) {
20126
- const excess = tasks.length - v.maxTasks;
20170
+ if (v?.maxTasks !== undefined && count > v.maxTasks) {
20171
+ const excess = count - v.maxTasks;
20127
20172
  ctx.addIssue({
20128
20173
  code: "custom",
20129
20174
  path: ["tasks"],
20130
- message: `Template has ${tasks.length} task(s), but maximum is ${v.maxTasks}. Remove ${excess} task(s) or increase maxTasks.`,
20131
- 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 }
20132
20177
  });
20133
20178
  }
20134
20179
  }
@@ -20150,14 +20195,14 @@ function validateUniqueTaskIds(tasks, ctx) {
20150
20195
  });
20151
20196
  });
20152
20197
  }
20153
- function validateEstimationConstraints(v, totalPercent, maxTotalPercent, ctx) {
20198
+ function validateEstimationConstraints(v, totalPercent, maxTotalPercent, hasRepeats, ctx) {
20154
20199
  if (v?.totalEstimationMustBe !== undefined) {
20155
20200
  if (v.totalEstimationMustBe < totalPercent || v.totalEstimationMustBe > maxTotalPercent) {
20156
20201
  ctx.addIssue({
20157
20202
  code: "custom",
20158
20203
  path: ["tasks"],
20159
20204
  message: `Total estimation is ${totalPercent}%, but must be ${v.totalEstimationMustBe}%.`,
20160
- params: { code: "INVALID_TOTAL_ESTIMATION" }
20205
+ params: { code: "INVALID_TOTAL_ESTIMATION", hasRepeats }
20161
20206
  });
20162
20207
  }
20163
20208
  } else if (v?.totalEstimationRange) {
@@ -20167,7 +20212,7 @@ function validateEstimationConstraints(v, totalPercent, maxTotalPercent, ctx) {
20167
20212
  code: "custom",
20168
20213
  path: ["tasks"],
20169
20214
  message: `Total estimation is ${totalPercent}%, but must be between ${min}% and ${max}%.`,
20170
- params: { code: "INVALID_ESTIMATION_RANGE" }
20215
+ params: { code: "INVALID_ESTIMATION_RANGE", hasRepeats }
20171
20216
  });
20172
20217
  }
20173
20218
  }
@@ -20205,7 +20250,7 @@ function reportCircularDependencies(tasks, taskIndexById, ctx) {
20205
20250
  });
20206
20251
  }
20207
20252
  }
20208
- var DATE_OR_MACRO_RE, DateOrMacroSchema, REFERENCE_NAME_RE, ConditionOperatorSchema, ConditionValueSchema, MULTI_VALUE_STANDARD_FIELDS, MULTI_VALUE_FIELD_OPERATORS, ConditionSchema, SavedQuerySchema, FilterCriteriaSchema, EstimationPercentConditionSchema, TaskDefinitionSchema, NUMBER_PATTERN, EstimationConversionSchema, EstimationConfigSchema, ValidationModeSchema, ValidationConfigSchema, MetadataSchema, TaskTemplateBaseSchema, ExtendingTaskTemplateSchema, TaskTemplateSchema, MixinTemplateSchema, CURRENT_ITERATION = "@CurrentIteration", TEAM_AREAS = "@TeamAreas";
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";
20209
20254
  var init_schema = __esm(() => {
20210
20255
  init_zod();
20211
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;
@@ -20299,6 +20344,7 @@ var init_schema = __esm(() => {
20299
20344
  tags: exports_external.array(exports_external.string()).describe("Tags applied to the generated task.").optional(),
20300
20345
  condition: ConditionSchema.describe("Condition evaluated against the parent story. If it does not match, this task is skipped.").optional(),
20301
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(),
20302
20348
  assignTo: exports_external.string().describe("Email address or alias to assign this task to.").optional(),
20303
20349
  priority: exports_external.number().describe("Priority value for the generated task.").optional(),
20304
20350
  activity: exports_external.string().describe("Azure DevOps activity type for this task (e.g. 'Development', 'Testing', 'Design').").optional(),
@@ -20365,6 +20411,7 @@ var init_schema = __esm(() => {
20365
20411
  }).strict().describe("Sum of all non-conditional task percentages must fall within this range; bounds may exceed 100 when tasks span roles.").optional(),
20366
20412
  minTasks: exports_external.number().describe("Minimum number of tasks the template must define.").optional(),
20367
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(),
20368
20415
  taskEstimationRange: exports_external.object({
20369
20416
  min: exports_external.number().describe("Minimum allowed estimation percentage for a single task."),
20370
20417
  max: exports_external.number().describe("Maximum allowed estimation percentage for a single task.")
@@ -20419,12 +20466,13 @@ var init_schema = __esm(() => {
20419
20466
  let maxTotalPercent = 0;
20420
20467
  for (const task of tasks) {
20421
20468
  const [ownMin, ownMax] = ownPercentRange(task);
20422
- totalPercent += task.condition ? 0 : ownMin;
20423
- maxTotalPercent += ownMax;
20469
+ const copies = copyCount(task);
20470
+ totalPercent += task.condition ? 0 : ownMin * copies;
20471
+ maxTotalPercent += ownMax * copies;
20424
20472
  }
20425
20473
  const taskIds = new Set(tasks.map((t) => t.id).filter(Boolean));
20426
20474
  validateUniqueTaskIds(tasks, ctx);
20427
- validateEstimationConstraints(v, totalPercent, maxTotalPercent, ctx);
20475
+ validateEstimationConstraints(v, totalPercent, maxTotalPercent, tasks.some((t) => copyCount(t) > 1), ctx);
20428
20476
  validateTaskConstraints(v, tasks, ctx);
20429
20477
  validateRequiredTasks(v, tasks, ctx);
20430
20478
  const availableIds = Array.from(taskIds);
@@ -20433,6 +20481,7 @@ var init_schema = __esm(() => {
20433
20481
  if (t.id)
20434
20482
  taskIndexById.set(t.id, i);
20435
20483
  });
20484
+ validateRepeatedTasks(tasks, ctx, v?.maxRepeat ?? DEFAULT_MAX_REPEAT);
20436
20485
  tasks.forEach((task, index) => {
20437
20486
  task.dependsOn?.forEach((depId) => {
20438
20487
  if (!taskIds.has(depId)) {
@@ -20455,6 +20504,7 @@ var init_schema = __esm(() => {
20455
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()
20456
20505
  }).strict().superRefine((data, ctx) => {
20457
20506
  validateUniqueTaskIds(data.tasks, ctx);
20507
+ validateRepeatedTasks(data.tasks, ctx);
20458
20508
  });
20459
20509
  });
20460
20510
 
@@ -21270,16 +21320,32 @@ function totalEstimationPercent(tasks) {
21270
21320
  return tasks.reduce((sum, task) => sum + (task.estimationPercent ?? 0), 0);
21271
21321
  }
21272
21322
  function totalUnconditionalEstimationPercent(tasks) {
21273
- 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);
21274
21324
  }
21275
21325
  function shouldOfferOverageNormalization(tasks) {
21276
21326
  const total = totalEstimationPercent(tasks);
21277
21327
  return { shouldOffer: total > 100, total };
21278
21328
  }
21279
21329
  var init_estimation_distribution = __esm(() => {
21330
+ init_schema2();
21280
21331
  init_estimation_normalizer();
21281
21332
  });
21282
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
+
21283
21349
  // ../atomize-core/src/core/template-interpolator.ts
21284
21350
  function interpolateValue(template, story, onMissing) {
21285
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) => {
@@ -21314,8 +21380,9 @@ class EstimationCalculator {
21314
21380
  if (resolution.kind === "blank") {
21315
21381
  logger.warn(`Story ${story.id}: ${resolution.reason}. Task Estimates will be left blank.`);
21316
21382
  }
21317
- const calculatedTasks = [];
21318
21383
  const skippedTasks = [];
21384
+ const activeTasks = [];
21385
+ const resolvedPercents = new Map;
21319
21386
  for (const templateTask of templateTasks) {
21320
21387
  logger.debug(`Processing template task: ${templateTask.title} (Condition: ${templateTask.condition ? JSON.stringify(templateTask.condition) : "none"})`);
21321
21388
  if (templateTask.condition) {
@@ -21345,9 +21412,14 @@ class EstimationCalculator {
21345
21412
  skippedTasks.push({ templateTask, reason });
21346
21413
  continue;
21347
21414
  }
21348
- const calculatedTask = this.buildCalculatedTask(templateTask, story, connectUserEmail, 0, resolvedPercent);
21349
- calculatedTasks.push(calculatedTask);
21415
+ activeTasks.push(templateTask);
21416
+ resolvedPercents.set(templateTask, resolvedPercent);
21350
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
+ });
21351
21423
  if (fieldMapping) {
21352
21424
  for (const task of calculatedTasks) {
21353
21425
  task.workItemType = fieldMapping.taskWorkItemType;
@@ -89478,7 +89550,7 @@ class TemplateValidator {
89478
89550
  collectWarnings(template) {
89479
89551
  const warnings = [];
89480
89552
  const v2 = template.validation;
89481
- 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);
89482
89554
  const hasStrictRule = v2?.totalEstimationMustBe !== undefined || v2?.totalEstimationRange !== undefined;
89483
89555
  const normalize = template.estimation?.normalize ?? "auto";
89484
89556
  if (!hasStrictRule && total > 100 && normalize !== "always") {
@@ -89604,22 +89676,32 @@ class TemplateValidator {
89604
89676
  return this.handleZodError(err);
89605
89677
  }
89606
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." : "";
89607
89681
  switch (code) {
89608
89682
  case "INVALID_TOTAL_ESTIMATION":
89609
- 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) => {
89610
89684
  const diff = required2 - current;
89611
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}%.`;
89612
- });
89686
+ }));
89613
89687
  case "INVALID_ESTIMATION_RANGE":
89614
- 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) => {
89615
89689
  if (current < min)
89616
89690
  return `Increase task estimations by ${min - current}% to meet the minimum of ${min}%.`;
89617
89691
  return `Reduce task estimations by ${current - max}% to stay within the maximum of ${max}%.`;
89618
- });
89619
- case "TOO_FEW_TASKS":
89620
- 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.`);
89621
- case "TOO_MANY_TASKS":
89622
- 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
+ }
89623
89705
  case "INVALID_DEPENDENCY": {
89624
89706
  const match = err.message.match(/non-existent task ID: "([^"]+)"/);
89625
89707
  return match ? `Either add a task with id: "${match[1]}" or update the dependsOn field to reference an existing task ID.` : undefined;
@@ -89632,6 +89714,14 @@ class TemplateValidator {
89632
89714
  }
89633
89715
  return "Remove one of the dependencies in the cycle to break the circular reference.";
89634
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
+ }
89635
89725
  case "MISSING_REQUIRED_TASK": {
89636
89726
  const match = err.message.match(/title "([^"]+)"/);
89637
89727
  return match ? `Add a task with title "${match[1]}" to satisfy the required tasks constraint.` : "Add the missing required task to the template.";
@@ -89640,6 +89730,9 @@ class TemplateValidator {
89640
89730
  return;
89641
89731
  }
89642
89732
  }
89733
+ withHint(hint, suggestion) {
89734
+ return suggestion && `${suggestion}${hint}`;
89735
+ }
89643
89736
  handleNumericError(message, regex, formatter) {
89644
89737
  const values = this.extractValues(message, regex);
89645
89738
  if (!values || values.length === 0)
@@ -93384,6 +93477,12 @@ var init_prompt_utilities = __esm(() => {
93384
93477
  return required2;
93385
93478
  return Validators.maxLength(fieldName, maxLength)(input2);
93386
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
+ },
93387
93486
  positiveNumber: (fieldName) => (input2) => {
93388
93487
  const num = Number(input2);
93389
93488
  if (!input2 || input2.trim() === "" || Number.isNaN(num))
@@ -98679,7 +98778,7 @@ var program = new Command;
98679
98778
 
98680
98779
  // package.json
98681
98780
  var name = "@sppg2001/atomize";
98682
- var version2 = "4.1.0";
98781
+ var version2 = "4.2.0";
98683
98782
 
98684
98783
  // src/cli/commands/auth/auth-add.command.ts
98685
98784
  init_dist6();
@@ -99635,6 +99734,7 @@ init_dist6();
99635
99734
  init_logger2();
99636
99735
  init_estimation_distribution();
99637
99736
  init_platform_factory();
99737
+ init_schema2();
99638
99738
  init_template_library();
99639
99739
  init_errors();
99640
99740
 
@@ -100187,7 +100287,7 @@ function printReport(report, options, dryRun, storyIds) {
100187
100287
  continue;
100188
100288
  }
100189
100289
  output2.print(source_default.green(`✓ ${sanitizeTty(result.story.id)}: ${sanitizeTty(result.story.title)}`));
100190
- const withUnit = (value) => result.estimateUnit ? `${value} ${result.estimateUnit}` : String(value);
100290
+ const withUnit = (value) => result.estimateUnit ? `${formatEstimateValue(value)} ${result.estimateUnit}` : formatEstimateValue(value);
100191
100291
  const rawEstimate = result.storyEstimate;
100192
100292
  const converted = result.estimationSummary?.storyEstimation;
100193
100293
  const conversionNote = converted !== undefined && converted !== rawEstimate ? ` (converted to ${withUnit(converted)})` : "";
@@ -103502,6 +103602,7 @@ tasks: # required, minimum 1 item
103502
103602
  tags: string[] # optional
103503
103603
  id: string # optional, required if this task is referenced in dependsOn
103504
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)
103505
103606
  condition: # optional — skip this task entirely when condition is false
103506
103607
  # Standard fields (case-sensitive, always lowercase camelCase — NOT the ADO REST field
103507
103608
  # names like "System.State" or "Microsoft.VSTS.Common.Priority"):
@@ -103535,8 +103636,9 @@ estimation: # optional
103535
103636
  defaultParentEstimation: number | string # optional — Story Estimate to assume with use-default, e.g. 5 or "M"
103536
103637
 
103537
103638
  validation: # optional
103538
- minTasks: number
103639
+ minTasks: number # each copy of a repeated task counts
103539
103640
  maxTasks: number
103641
+ maxRepeat: number # optional, default 20 — the highest repeat any task may use
103540
103642
  totalEstimationMustBe: number # usually 100
103541
103643
  totalEstimationRange:
103542
103644
  min: number
@@ -103551,7 +103653,7 @@ metadata: # optional
103551
103653
 
103552
103654
  CONSTRAINTS (you must follow these):
103553
103655
  1. Estimation sums:
103554
- - 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.
103555
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.
103556
103658
  2. If a task uses dependsOn, all referenced IDs must exist on other tasks in the same template.
103557
103659
  3. workItemTypes values are case-sensitive strings matching ADO work item types.
@@ -103582,6 +103684,12 @@ CONSTRAINTS (you must follow these):
103582
103684
  - With a conversion table on a picklist source, use the picklist's allowed values exactly as keys and cover every one.
103583
103685
  - For a numeric scale where hours grow faster than points, use a table with numeric keys instead of a factor.
103584
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.
103585
103693
 
103586
103694
  EXAMPLES:
103587
103695
 
@@ -103785,7 +103893,32 @@ filter:
103785
103893
  tasks:
103786
103894
  - id: "implement"
103787
103895
  title: "Implement (Mobile): \${story.title}"
103788
- 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%."`;
103789
103922
  function buildSystemPrompt() {
103790
103923
  return SYSTEM_PROMPT;
103791
103924
  }
@@ -103854,6 +103987,7 @@ init_prompt_utilities();
103854
103987
 
103855
103988
  // src/cli/commands/template/template-wizard.ts
103856
103989
  init_dist6();
103990
+ init_schema2();
103857
103991
  init_errors();
103858
103992
  init_estimation_normalizer();
103859
103993
  init_source();
@@ -104348,12 +104482,22 @@ async function configureTaskTags(defaults) {
104348
104482
  return feature.data.length > 0 ? feature.data : undefined;
104349
104483
  }
104350
104484
  async function configureAdvancedTaskOptions(storyFieldSchemas, defaults) {
104351
- const dependsOnRaw = assertNotCancelled(await text({
104352
- message: "Depends on task IDs (comma-separated, optional):",
104353
- placeholder: "e.g. task-setup, task-db, task-build, task-test",
104354
- 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")
104355
104490
  }));
104356
- 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
+ }
104357
104501
  const addCondition = assertNotCancelled(await confirm({ message: "Add a task condition?", initialValue: false }));
104358
104502
  let condition;
104359
104503
  if (addCondition) {
@@ -104366,6 +104510,8 @@ async function configureAdvancedTaskOptions(storyFieldSchemas, defaults) {
104366
104510
  validate: Validators.priorityRange
104367
104511
  }));
104368
104512
  const result = {};
104513
+ if (repeat > 1)
104514
+ result.repeat = repeat;
104369
104515
  if (dependsOn.length > 0)
104370
104516
  result.dependsOn = dependsOn;
104371
104517
  if (condition)
@@ -104409,6 +104555,8 @@ async function buildTaskDefinition(isFirstTask, includeAdvanced, fieldSchemas, s
104409
104555
  }
104410
104556
  const advanced = await configureAdvancedTaskOptions(storyFieldSchemas, defaults);
104411
104557
  Object.assign(taskDef, advanced);
104558
+ } else if (defaults?.repeat !== undefined) {
104559
+ taskDef.repeat = defaults.repeat;
104412
104560
  }
104413
104561
  const customFields = await configureCustomFields(fieldSchemas, storyFieldSchemas);
104414
104562
  if (Object.keys(customFields).length > 0) {
@@ -104437,6 +104585,7 @@ function normalizeEstimations(tasks) {
104437
104585
  }
104438
104586
  }
104439
104587
  async function previewTemplate(template, ctx) {
104588
+ await offerRepeatLimit(template);
104440
104589
  displayTemplatePreview(template);
104441
104590
  const action = assertNotCancelled(await select({
104442
104591
  message: "What would you like to do?",
@@ -104449,6 +104598,21 @@ async function previewTemplate(template, ctx) {
104449
104598
  }));
104450
104599
  return await handlePreviewAction(action, template, ctx);
104451
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
+ }
104452
104616
  function displayTemplatePreview(template) {
104453
104617
  output4.print(source_default.cyan(`
104454
104618
  Template Preview
@@ -104489,13 +104653,16 @@ Filter Configuration:`));
104489
104653
  }
104490
104654
  }
104491
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`;
104492
104658
  output4.print(source_default.bold(`
104493
- Tasks (${template.tasks.length}):`));
104494
- const totalEstimation = template.tasks.reduce((sum2, task) => sum2 + (task.estimationPercent || 0), 0);
104659
+ Tasks (${template.tasks.length}${counted}):`));
104660
+ const totalEstimation = totalTaskPercent(template.tasks);
104495
104661
  template.tasks.forEach((task, index) => {
104496
104662
  const percent = task.estimationPercent || 0;
104497
104663
  const bar = "■".repeat(Math.round(percent / 5));
104498
- 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)}`);
104499
104666
  if (task.description) {
104500
104667
  output4.print(source_default.gray(` ${task.description}`));
104501
104668
  }
@@ -104525,6 +104692,9 @@ Validation Rules:`));
104525
104692
  if (template.validation.minTasks || template.validation.maxTasks) {
104526
104693
  output4.print(` Task count: ${template.validation.minTasks || "no min"} - ${template.validation.maxTasks || "no max"}`);
104527
104694
  }
104695
+ if (template.validation.maxRepeat !== undefined) {
104696
+ output4.print(` Copies per task: up to ${template.validation.maxRepeat}`);
104697
+ }
104528
104698
  }
104529
104699
  function displayMetadata(template) {
104530
104700
  if (!template.metadata)
@@ -104726,13 +104896,17 @@ async function shouldAddMoreTasks(taskCounter) {
104726
104896
  }));
104727
104897
  }
104728
104898
  async function handleEstimationNormalization(tasks) {
104729
- const totalEstimation = tasks.reduce((sum2, task) => sum2 + (task.estimationPercent || 0), 0);
104899
+ const totalEstimation = totalTaskPercent(tasks);
104730
104900
  if (totalEstimation === 100) {
104731
104901
  output4.print(source_default.green("✓ Total estimation is 100%"));
104732
104902
  return;
104733
104903
  }
104734
104904
  output4.print(source_default.yellow(`
104735
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
+ }
104736
104910
  const shouldNormalize = await promptForNormalization();
104737
104911
  if (shouldNormalize) {
104738
104912
  normalizeEstimations(tasks);
@@ -104770,7 +104944,7 @@ async function editTasksInteractively(tasks, fieldSchemas, storyFieldSchemas) {
104770
104944
  Tasks (${currentTasks.length}) — total: ${totalLabel}
104771
104945
  `));
104772
104946
  currentTasks.forEach((t3, i3) => {
104773
- 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}%]`));
104774
104948
  });
104775
104949
  output4.blankLine();
104776
104950
  };
@@ -104792,7 +104966,7 @@ Tasks (${currentTasks.length}) — total: ${totalLabel}
104792
104966
  const idx = assertNotCancelled(await select({
104793
104967
  message: "Select task to edit:",
104794
104968
  options: currentTasks.map((t3, i3) => ({
104795
- label: `${i3 + 1}. ${t3.title} [${t3.estimationPercent ?? 0}%]`,
104969
+ label: `${i3 + 1}. ${t3.title}${copyCount(t3) > 1 ? ` ×${copyCount(t3)}` : ""} [${t3.estimationPercent ?? 0}%]`,
104796
104970
  value: i3
104797
104971
  }))
104798
104972
  }));
@@ -104809,7 +104983,7 @@ Tasks (${currentTasks.length}) — total: ${totalLabel}
104809
104983
  const idx = assertNotCancelled(await select({
104810
104984
  message: "Select task to remove:",
104811
104985
  options: currentTasks.map((t3, i3) => ({
104812
- label: `${i3 + 1}. ${t3.title} [${t3.estimationPercent ?? 0}%]`,
104986
+ label: `${i3 + 1}. ${t3.title}${copyCount(t3) > 1 ? ` ×${copyCount(t3)}` : ""} [${t3.estimationPercent ?? 0}%]`,
104813
104987
  value: i3
104814
104988
  }))
104815
104989
  }));
@@ -106509,6 +106683,7 @@ init_dist6();
106509
106683
  init_logger2();
106510
106684
  init_estimation_distribution();
106511
106685
  init_custom_field_verifier();
106686
+ init_schema2();
106512
106687
  init_template_library();
106513
106688
  init_errors();
106514
106689
  init_source();
@@ -106743,7 +106918,7 @@ function getTemplateSummary(template) {
106743
106918
  const totalPercent = totalUnconditionalEstimationPercent(template.tasks);
106744
106919
  return {
106745
106920
  name: template.name,
106746
- tasks: template.tasks.length,
106921
+ tasks: generatedTaskCount(template.tasks),
106747
106922
  totalEstimation: `${totalPercent}%`
106748
106923
  };
106749
106924
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sppg2001/atomize",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "Automatically generate tasks from user stories with smart templates",
5
5
  "type": "module",
6
6
  "main": "./dist/cli/index.js",