hammerkit 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/README.md +4 -4
  2. package/build.schema.json +49 -2
  3. package/dist/cache/backends/local-cache-backend.js +58 -1
  4. package/dist/cache/backends/registry/registry-client.js +264 -0
  5. package/dist/cache/backends/registry/registry-credentials.js +81 -0
  6. package/dist/cache/backends/registry/registry-reference.js +36 -0
  7. package/dist/cache/backends/registry-cache-backend.js +145 -0
  8. package/dist/cache/backends/s3-cache-backend.js +99 -46
  9. package/dist/cache/cache-backend-registry.js +2 -0
  10. package/dist/cache/cache-inventory.js +84 -0
  11. package/dist/cache/cache-sync.js +77 -0
  12. package/dist/cache/explain.js +214 -0
  13. package/dist/cache/last-resolved.js +48 -0
  14. package/dist/cache/read-only.js +15 -0
  15. package/dist/cache/retention.js +61 -0
  16. package/dist/cli.js +43 -0
  17. package/dist/docker/using-container.js +5 -1
  18. package/dist/executer/archive-task-entry.js +21 -0
  19. package/dist/executer/await-completed-dependencies.js +32 -14
  20. package/dist/executer/cache-metadata.js +9 -2
  21. package/dist/executer/docker-service.js +2 -1
  22. package/dist/executer/docker-task.js +2 -1
  23. package/dist/executer/dry-run.js +73 -0
  24. package/dist/executer/event-cache.js +6 -5
  25. package/dist/executer/execute-command.js +21 -3
  26. package/dist/executer/execute-work-service.js +10 -1
  27. package/dist/executer/execute-work-task.js +95 -25
  28. package/dist/executer/execute-work-tree.js +26 -1
  29. package/dist/executer/get-scheduler-execute-result.js +4 -1
  30. package/dist/executer/lazy-resolver.js +15 -3
  31. package/dist/executer/reset-work-tree.js +2 -0
  32. package/dist/executer/run-summary.js +102 -0
  33. package/dist/executer/scheduler/enqueue-next.js +15 -60
  34. package/dist/executer/scheduler/state-key.js +78 -0
  35. package/dist/kubernetes/await-running-state.js +73 -19
  36. package/dist/log.js +16 -6
  37. package/dist/logging/live-logger.js +2 -1
  38. package/dist/optimizer/calculate-checksum.js +8 -2
  39. package/dist/optimizer/get-work-cache-stats.js +66 -16
  40. package/dist/optimizer/work-task-cache-description.js +34 -5
  41. package/dist/planner/project-root.js +26 -0
  42. package/dist/planner/utils/append-work-service.js +2 -1
  43. package/dist/planner/utils/append-work-task.js +7 -1
  44. package/dist/planner/utils/parse-work-source.js +35 -30
  45. package/dist/planner/utils/portable-path.js +30 -0
  46. package/dist/planner/work-cache-id.js +2 -2
  47. package/dist/planner/work-graph-serializer.js +103 -0
  48. package/dist/planner/work-instance-id.js +15 -0
  49. package/dist/planner/work-runtime-docker.js +53 -15
  50. package/dist/planner/work-runtime-kubernetes.js +35 -3
  51. package/dist/planner/work-runtime-local.js +9 -3
  52. package/dist/planner/work-service-id.js +16 -4
  53. package/dist/program.js +235 -1
  54. package/dist/schema/build-file-base-task-schema.js +4 -0
  55. package/dist/schema/cache-schema.js +21 -2
  56. package/dist/schema/duration-schema.js +18 -0
  57. package/dist/schema/reference-parser.js +3 -0
  58. package/dist/schema/size-schema.js +18 -0
  59. package/dist/schema/work-scope-parser.js +15 -2
  60. package/dist/utils/units.js +87 -0
  61. package/package.json +12 -1
  62. package/.agents/FEEDBACK.md +0 -74
  63. package/.agents/skills/before-commit/SKILL.md +0 -101
  64. package/.agents/skills/do-the-work/SKILL.md +0 -64
  65. package/.agents/skills/fix-ci/SKILL.md +0 -98
  66. package/.agents/skills/speckit-git-commit/SKILL.md +0 -53
  67. package/.agents/skills/speckit-git-feature/SKILL.md +0 -72
  68. package/.agents/skills/speckit-git-initialize/SKILL.md +0 -54
  69. package/.agents/skills/speckit-git-remote/SKILL.md +0 -50
  70. package/.agents/skills/speckit-git-validate/SKILL.md +0 -54
  71. package/.devcontainer/Dockerfile +0 -5
  72. package/.devcontainer/devcontainer.json +0 -40
  73. package/.gitbook.yaml +0 -5
  74. package/.specify/extensions/.registry +0 -37
  75. package/.specify/extensions/git/README.md +0 -100
  76. package/.specify/extensions/git/commands/speckit.git.commit.md +0 -48
  77. package/.specify/extensions/git/commands/speckit.git.feature.md +0 -67
  78. package/.specify/extensions/git/commands/speckit.git.initialize.md +0 -49
  79. package/.specify/extensions/git/commands/speckit.git.remote.md +0 -45
  80. package/.specify/extensions/git/commands/speckit.git.validate.md +0 -49
  81. package/.specify/extensions/git/config-template.yml +0 -62
  82. package/.specify/extensions/git/extension.yml +0 -140
  83. package/.specify/extensions/git/git-config.yml +0 -62
  84. package/.specify/extensions.yml +0 -149
  85. package/.specify/feature.json +0 -3
  86. package/.specify/init-options.json +0 -10
  87. package/.specify/integration.json +0 -15
  88. package/.specify/integrations/claude.manifest.json +0 -16
  89. package/.specify/integrations/speckit.manifest.json +0 -17
  90. package/.specify/memory/constitution.md +0 -162
  91. package/.specify/templates/checklist-template.md +0 -40
  92. package/.specify/templates/constitution-template.md +0 -50
  93. package/.specify/templates/plan-template.md +0 -113
  94. package/.specify/templates/spec-template.md +0 -131
  95. package/.specify/templates/tasks-template.md +0 -252
  96. package/.specify/workflows/speckit/workflow.yml +0 -77
  97. package/.specify/workflows/workflow-registry.json +0 -13
  98. package/AGENTS.md +0 -94
  99. package/Dockerfile +0 -32
  100. package/FOLLOWUPS.md +0 -34
  101. package/SUMMARY.md +0 -68
  102. package/VALUES.md +0 -53
  103. package/dist/cache/auto-pull.spec.js +0 -123
  104. package/dist/cache/backends/local-cache-backend.spec.js +0 -72
  105. package/dist/cache/backends/s3-cache-backend.spec.js +0 -118
  106. package/dist/cache/cache-backend-registry.spec.js +0 -22
  107. package/dist/cache/resolve-cache.spec.js +0 -52
  108. package/dist/cli.spec.js +0 -23
  109. package/dist/console/get-logger.spec.js +0 -23
  110. package/dist/docker/get-default-platform.spec.js +0 -29
  111. package/dist/docker/longest-common-prefix.spec.js +0 -26
  112. package/dist/docker/package.spec.js +0 -145
  113. package/dist/docker/remove-container.spec.js +0 -32
  114. package/dist/docker/using-container.spec.js +0 -90
  115. package/dist/environment/merge-environment-variables.spec.js +0 -24
  116. package/dist/environment/replace-env-variables.spec.js +0 -47
  117. package/dist/executer/abort.spec.js +0 -18
  118. package/dist/executer/environment-mock.spec.js +0 -22
  119. package/dist/executer/execute.spec.js +0 -51
  120. package/dist/executer/get-service-containers.spec.js +0 -59
  121. package/dist/executer/get-service-env-hints.spec.js +0 -65
  122. package/dist/executer/label-values.spec.js +0 -61
  123. package/dist/executer/lazy-resolver.spec.js +0 -20
  124. package/dist/executer/process-manager.spec.js +0 -54
  125. package/dist/executer/scheduler/enqueue-next.spec.js +0 -136
  126. package/dist/executer/state-resolver.spec.js +0 -37
  127. package/dist/executer/state.spec.js +0 -66
  128. package/dist/executer/states.spec.js +0 -14
  129. package/dist/file/get-file-context.spec.js +0 -178
  130. package/dist/file/move-files.spec.js +0 -48
  131. package/dist/kubernetes/ensure-http-route.spec.js +0 -70
  132. package/dist/kubernetes/ensure-namespace.spec.js +0 -39
  133. package/dist/kubernetes/resolve-pod-name.spec.js +0 -81
  134. package/dist/log.spec.js +0 -131
  135. package/dist/logging/grouped-logger.spec.js +0 -84
  136. package/dist/logging/interactive-logger.spec.js +0 -57
  137. package/dist/logging/live-logger.spec.js +0 -37
  138. package/dist/logging/loggers.spec.js +0 -154
  139. package/dist/optimizer/calculate-checksum.spec.js +0 -34
  140. package/dist/optimizer/get-cache-directory.spec.js +0 -59
  141. package/dist/optimizer/get-work-cache-stats.spec.js +0 -79
  142. package/dist/optimizer/work-task-cache-description.spec.js +0 -45
  143. package/dist/parser/default-build-file.spec.js +0 -48
  144. package/dist/parser/parse-label-arguments.spec.js +0 -27
  145. package/dist/parser/read-build-file.spec.js +0 -89
  146. package/dist/parser/read-env-file.spec.js +0 -42
  147. package/dist/planner/utils/append-work-service.spec.js +0 -71
  148. package/dist/planner/utils/get-container-user.spec.js +0 -30
  149. package/dist/planner/utils/get-default-kube-config.spec.js +0 -11
  150. package/dist/planner/utils/normalize-path.spec.js +0 -24
  151. package/dist/planner/utils/parse-work-command.spec.js +0 -21
  152. package/dist/planner/utils/parse-work-generate.spec.js +0 -45
  153. package/dist/planner/utils/parse-work-mount.spec.js +0 -62
  154. package/dist/planner/utils/parse-work-mounts.spec.js +0 -19
  155. package/dist/planner/utils/parse-work-port.spec.js +0 -24
  156. package/dist/planner/utils/parse-work-ports.spec.js +0 -24
  157. package/dist/planner/utils/parse-work-source.spec.js +0 -58
  158. package/dist/planner/utils/parse-work-volume.spec.js +0 -18
  159. package/dist/planner/utils/plan-work-tasks.spec.js +0 -30
  160. package/dist/planner/utils/plan-work-volume.spec.js +0 -33
  161. package/dist/planner/utils/split-name.spec.js +0 -16
  162. package/dist/planner/utils/template-value.spec.js +0 -14
  163. package/dist/planner/validate.spec.js +0 -251
  164. package/dist/planner/work-cache-id.spec.js +0 -173
  165. package/dist/planner/work-runtime-local.spec.js +0 -193
  166. package/dist/program-commands.spec.js +0 -84
  167. package/dist/program.spec.js +0 -43
  168. package/dist/schema/build-file-schema.spec.js +0 -61
  169. package/dist/schema/doc-build-files.spec.js +0 -223
  170. package/dist/schema/parse-error.spec.js +0 -18
  171. package/dist/schema/reference-parser.spec.js +0 -83
  172. package/dist/schema/schema-parser.spec.js +0 -80
  173. package/dist/schema/type-guards.spec.js +0 -23
  174. package/dist/schema/work-scope-parser.spec.js +0 -66
  175. package/dist/service/update-service-status.spec.js +0 -27
  176. package/dist/testing/ensure-local-registry.js +0 -134
  177. package/dist/testing/example-test-suite.js +0 -63
  178. package/dist/testing/expect.js +0 -82
  179. package/dist/testing/get-test-suite.js +0 -9
  180. package/dist/testing/http-get.js +0 -21
  181. package/dist/testing/integration/cache.spec.js +0 -49
  182. package/dist/testing/integration/cancellation.spec.js +0 -45
  183. package/dist/testing/integration/clean.spec.js +0 -43
  184. package/dist/testing/integration/cmd.spec.js +0 -15
  185. package/dist/testing/integration/concurrency.spec.js +0 -19
  186. package/dist/testing/integration/docker-package-registry.spec.js +0 -44
  187. package/dist/testing/integration/docker-service-crash.spec.js +0 -36
  188. package/dist/testing/integration/docker.spec.js +0 -18
  189. package/dist/testing/integration/env.spec.js +0 -48
  190. package/dist/testing/integration/environment.spec.js +0 -97
  191. package/dist/testing/integration/error.spec.js +0 -19
  192. package/dist/testing/integration/export.spec.js +0 -22
  193. package/dist/testing/integration/extend.spec.js +0 -29
  194. package/dist/testing/integration/glob.spec.js +0 -40
  195. package/dist/testing/integration/include.spec.js +0 -31
  196. package/dist/testing/integration/invalid.spec.js +0 -17
  197. package/dist/testing/integration/invalid_loop.spec.js +0 -56
  198. package/dist/testing/integration/k8s-state-cache.spec.js +0 -41
  199. package/dist/testing/integration/kubernetes-service.spec.js +0 -148
  200. package/dist/testing/integration/kubernetes.spec.js +0 -99
  201. package/dist/testing/integration/labels.spec.js +0 -42
  202. package/dist/testing/integration/local.spec.js +0 -14
  203. package/dist/testing/integration/monorepo.spec.js +0 -15
  204. package/dist/testing/integration/multi-line.spec.js +0 -17
  205. package/dist/testing/integration/reference.spec.js +0 -21
  206. package/dist/testing/integration/services.spec.js +0 -38
  207. package/dist/testing/integration/store.spec.js +0 -52
  208. package/dist/testing/integration/unknown.spec.js +0 -28
  209. package/dist/testing/integration/validate.spec.js +0 -160
  210. package/dist/testing/integration/watch-dependency.spec.js +0 -33
  211. package/dist/testing/integration/watch.spec.js +0 -45
  212. package/dist/testing/requires-kubernetes.js +0 -18
  213. package/dist/testing/requires-linux-containers.js +0 -18
  214. package/dist/testing/requires-windows-containers.js +0 -18
  215. package/dist/testing/test-case.js +0 -75
  216. package/dist/testing/test-streams.js +0 -28
  217. package/dist/testing/test-suite-setup.js +0 -3
  218. package/dist/testing/test-suite.js +0 -3
  219. package/dist/testing/testing-timeout.js +0 -32
  220. package/dist/utils/abort-event.spec.js +0 -46
  221. package/dist/utils/buffer-context.spec.js +0 -38
  222. package/dist/utils/ci.spec.js +0 -53
  223. package/dist/utils/debouncer.spec.js +0 -58
  224. package/dist/utils/emitter.spec.js +0 -34
  225. package/dist/utils/empty-writable.spec.js +0 -18
  226. package/dist/utils/environment-config.spec.js +0 -26
  227. package/dist/utils/fail-never.spec.js +0 -9
  228. package/dist/utils/sleep.spec.js +0 -22
  229. package/dist/version.spec.js +0 -12
  230. package/specs/build-file/caches/spec.md +0 -114
  231. package/specs/build-file/environment-variables/spec.md +0 -82
  232. package/specs/build-file/includes/spec.md +0 -60
  233. package/specs/build-file/references/spec.md +0 -56
  234. package/specs/build-file/spec.md +0 -62
  235. package/specs/cli/clean/spec.md +0 -85
  236. package/specs/cli/down/spec.md +0 -85
  237. package/specs/cli/execute/spec.md +0 -101
  238. package/specs/cli/init/spec.md +0 -64
  239. package/specs/cli/ls/spec.md +0 -66
  240. package/specs/cli/package/spec.md +0 -110
  241. package/specs/cli/spec.md +0 -73
  242. package/specs/cli/store-restore/spec.md +0 -74
  243. package/specs/cli/up/spec.md +0 -80
  244. package/specs/cli/validate/spec.md +0 -70
  245. package/specs/labels/spec.md +0 -91
  246. package/specs/service/container/spec.md +0 -120
  247. package/specs/service/kubernetes/spec.md +0 -97
  248. package/specs/service/spec.md +0 -105
  249. package/specs/task/caching/spec.md +0 -107
  250. package/specs/task/container/spec.md +0 -118
  251. package/specs/task/dependencies/spec.md +0 -70
  252. package/specs/task/extending/spec.md +0 -74
  253. package/specs/task/kubernetes/spec.md +0 -125
  254. package/specs/task/needs/spec.md +0 -59
  255. package/specs/task/spec.md +0 -87
  256. package/specs/task/watching/spec.md +0 -69
package/dist/program.js CHANGED
@@ -10,6 +10,10 @@ const ci_1 = require("./utils/ci");
10
10
  const parse_label_arguments_1 = require("./parser/parse-label-arguments");
11
11
  const cli_1 = require("./cli");
12
12
  const log_1 = require("./log");
13
+ const empty_writable_1 = require("./utils/empty-writable");
14
+ const explain_1 = require("./cache/explain");
15
+ const run_summary_1 = require("./executer/run-summary");
16
+ const dry_run_1 = require("./executer/dry-run");
13
17
  const label_values_1 = require("./executer/label-values");
14
18
  const default_build_file_1 = require("./parser/default-build-file");
15
19
  const schema_parser_1 = require("./schema/schema-parser");
@@ -17,6 +21,9 @@ const work_scope_parser_1 = require("./schema/work-scope-parser");
17
21
  const reference_parser_1 = require("./schema/reference-parser");
18
22
  const colors_1 = __importDefault(require("colors"));
19
23
  const version_1 = require("./version");
24
+ const read_only_1 = require("./cache/read-only");
25
+ const units_1 = require("./utils/units");
26
+ const cache_inventory_1 = require("./cache/cache-inventory");
20
27
  async function createCli(fileName, environment, workScope) {
21
28
  const { ctx, scope } = await (0, schema_parser_1.createParseContext)(fileName, environment);
22
29
  const referencedScope = await (0, reference_parser_1.parseReferences)(ctx, scope, environment);
@@ -233,6 +240,179 @@ async function getProgram(environment, argv) {
233
240
  program.error('Detected errors in the hammerkit configuration', { exitCode: 1 });
234
241
  }
235
242
  });
243
+ program
244
+ .command('graph [task]')
245
+ .description('serialize the build graph (tasks, services, deps/needs edges) as mermaid or dot')
246
+ .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
247
+ .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
248
+ .addOption(new commander_1.Option('--env <name>', 'environment'))
249
+ .addOption(new commander_1.Option('--format <format>', 'output format').default('mermaid').choices(['mermaid', 'dot']))
250
+ .action(async (task, options) => {
251
+ try {
252
+ const cli = await createCli(fileName, environment, task ? { taskName: task, environmentName: options.env ?? null } : parseWorkLabelScope(options));
253
+ const serialization = cli.graph(options.format);
254
+ environment.stdout.write(`${serialization.output}\n`);
255
+ if (serialization.cycle) {
256
+ environment.console.warn(`cycle detected: ${serialization.cycle.join(' -> ')}`);
257
+ }
258
+ }
259
+ catch (e) {
260
+ if (e instanceof commander_1.CommanderError) {
261
+ throw e;
262
+ }
263
+ environment.console.error((0, log_1.getErrorMessage)(e));
264
+ program.error('Graph was not successful', { exitCode: 1 });
265
+ }
266
+ });
267
+ program
268
+ .command('explain [task]')
269
+ .description('explain whether tasks would be a cache hit or miss, without running them')
270
+ .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
271
+ .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
272
+ .addOption(new commander_1.Option('--env <name>', 'environment'))
273
+ .addOption(new commander_1.Option('--cache <method>', 'caching method to compare')
274
+ .default('checksum')
275
+ .choices(['checksum', 'modify-date', 'none']))
276
+ .addOption(new commander_1.Option('--json', 'emit the explanation as JSON').default(false))
277
+ .action(async (task, options) => {
278
+ try {
279
+ const cli = await createCli(fileName, environment, task ? { taskName: task, environmentName: options.env ?? null } : parseWorkLabelScope(options));
280
+ const explanations = await cli.explain({ cacheDefault: options.cache });
281
+ if (options.json) {
282
+ environment.stdout.write(`${JSON.stringify(explanations, null, 2)}\n`);
283
+ return;
284
+ }
285
+ for (const explanation of explanations) {
286
+ const label = explanation.status === 'hit'
287
+ ? colors_1.default.green('cache hit')
288
+ : explanation.status === 'uncacheable'
289
+ ? colors_1.default.grey('uncacheable')
290
+ : colors_1.default.yellow('cache miss');
291
+ environment.stdout.write(`• ${explanation.taskName}: ${label}\n`);
292
+ for (const cause of explanation.causes) {
293
+ (0, log_1.printProperty)(environment, 'cause', (0, explain_1.describeCause)(cause));
294
+ }
295
+ }
296
+ }
297
+ catch (e) {
298
+ if (e instanceof commander_1.CommanderError) {
299
+ throw e;
300
+ }
301
+ environment.console.error((0, log_1.getErrorMessage)(e));
302
+ program.error('Explain was not successful', { exitCode: 1 });
303
+ }
304
+ });
305
+ const cacheCommand = program.command('cache').description('inspect, prune and move cache entries');
306
+ for (const direction of ['pull', 'push']) {
307
+ cacheCommand
308
+ .command(`${direction} [task]`)
309
+ .description(direction === 'pull'
310
+ ? 'fetch the cache entries of the current state from a remote cache, without running tasks'
311
+ : 'upload the locally cached entries of the current state to a remote cache, without running tasks')
312
+ .addOption(new commander_1.Option('--remote <name>', 'remote cache declared in the caches block').makeOptionMandatory())
313
+ .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
314
+ .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
315
+ .addOption(new commander_1.Option('--env <name>', 'environment'))
316
+ .addOption(new commander_1.Option('--cache <method>', 'caching method to compare')
317
+ .default('checksum')
318
+ .choices(['checksum', 'modify-date', 'none']))
319
+ .action(async (task, options) => {
320
+ try {
321
+ if (direction === 'push' && (0, read_only_1.isCacheReadOnly)(false, environment.processEnvs)) {
322
+ program.error(`cache is read-only (${read_only_1.CACHE_READ_ONLY_ENV} is set), refusing to push`, { exitCode: 1 });
323
+ return;
324
+ }
325
+ const cli = await createCli(fileName, environment, task ? { taskName: task, environmentName: options.env ?? null } : parseWorkLabelScope(options));
326
+ const results = await cli.syncCache({ direction, remote: options.remote, cacheDefault: options.cache });
327
+ const labels = {
328
+ transferred: colors_1.default.green(direction === 'pull' ? 'pulled' : 'pushed'),
329
+ present: colors_1.default.grey('already present'),
330
+ missing: colors_1.default.yellow(direction === 'pull' ? 'not in remote' : 'not in local cache'),
331
+ skipped: colors_1.default.grey('skipped'),
332
+ };
333
+ for (const result of results) {
334
+ environment.stdout.write(`• ${result.taskName}: ${labels[result.status]}\n`);
335
+ }
336
+ const moved = results.filter((r) => r.status === 'transferred').length;
337
+ environment.stdout.write(`${moved}/${results.length} entries ${direction === 'pull' ? 'pulled' : 'pushed'}\n`);
338
+ }
339
+ catch (e) {
340
+ if (e instanceof commander_1.CommanderError) {
341
+ throw e;
342
+ }
343
+ environment.console.error((0, log_1.getErrorMessage)(e));
344
+ program.error(`Cache ${direction} was not successful: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
345
+ }
346
+ });
347
+ }
348
+ const describeEntry = (entry) => `${entry.taskName ?? colors_1.default.grey(entry.taskId.substring(0, 12))} ${colors_1.default.grey(entry.stateKey.substring(0, 12))} ` +
349
+ `${entry.size === null ? '?' : (0, units_1.formatSize)(entry.size)}` +
350
+ (entry.lastAccessedAt ?? entry.createdAt
351
+ ? colors_1.default.grey(` last used ${(0, units_1.formatDuration)(Date.now() - (entry.lastAccessedAt ?? entry.createdAt ?? 0))} ago`)
352
+ : '');
353
+ const totalSize = (entries) => (0, units_1.formatSize)(entries.reduce((sum, entry) => sum + (entry.size ?? 0), 0));
354
+ const countOf = (count) => `${count} ${count === 1 ? 'entry' : 'entries'}`;
355
+ cacheCommand
356
+ .command('ls')
357
+ .description('list the entries of a cache (default: the local default cache)')
358
+ .addOption(new commander_1.Option('--remote <name>', 'cache declared in the caches block to list'))
359
+ .addOption(new commander_1.Option('--json', 'emit the entries as JSON').default(false))
360
+ .action(async (options) => {
361
+ try {
362
+ const cli = await createCli(fileName, environment, parseWorkLabelScope({}));
363
+ const entries = await cli.listCache(options.remote);
364
+ if (options.json) {
365
+ environment.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
366
+ return;
367
+ }
368
+ for (const entry of entries) {
369
+ environment.stdout.write(`• ${describeEntry(entry)}\n`);
370
+ }
371
+ environment.stdout.write(`${countOf(entries.length)}, ${totalSize(entries)} total\n`);
372
+ }
373
+ catch (e) {
374
+ if (e instanceof commander_1.CommanderError) {
375
+ throw e;
376
+ }
377
+ program.error(`Cache ls was not successful: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
378
+ }
379
+ });
380
+ cacheCommand
381
+ .command('prune')
382
+ .description('remove cache entries by retention policy (default: the local default cache)')
383
+ .addOption(new commander_1.Option('--remote <name>', 'cache declared in the caches block to prune'))
384
+ .addOption(new commander_1.Option('--max-age <duration>', 'remove entries not used within this long (e.g. 30d)').argParser(units_1.parseDuration))
385
+ .addOption(new commander_1.Option('--max-size <size>', 'remove least recently used entries above this size (e.g. 5Gi)').argParser(units_1.parseSize))
386
+ .addOption(new commander_1.Option('--keep <count>', 'keep only the newest versions per task').argParser((v) => parseInt(v, 10)))
387
+ .addOption(new commander_1.Option('--dry-run', 'show what would be removed without removing it').default(false))
388
+ .action(async (options) => {
389
+ try {
390
+ const cli = await createCli(fileName, environment, parseWorkLabelScope({}));
391
+ const policy = cli.retentionPolicy(options.remote, {
392
+ maxAge: options.maxAge,
393
+ maxSize: options.maxSize,
394
+ keepPerTask: options.keep,
395
+ });
396
+ if (!(0, cache_inventory_1.hasPolicy)(policy)) {
397
+ program.error('no retention policy: pass --max-age, --max-size or --keep, or declare retention on the cache', { exitCode: 1 });
398
+ return;
399
+ }
400
+ const plan = await cli.pruneCache(options.remote, policy, options.dryRun);
401
+ for (const entry of plan.evict) {
402
+ environment.stdout.write(`• ${options.dryRun ? 'would remove' : 'removed'} ${describeEntry(entry)}\n`);
403
+ }
404
+ for (const note of plan.unavailable) {
405
+ environment.console.warn(note);
406
+ }
407
+ environment.stdout.write(`${options.dryRun ? 'would remove' : 'removed'} ${countOf(plan.evict.length)} (${totalSize(plan.evict)}), ` + `kept ${countOf(plan.keep.length)} (${totalSize(plan.keep)})\n`);
408
+ }
409
+ catch (e) {
410
+ if (e instanceof commander_1.CommanderError) {
411
+ throw e;
412
+ }
413
+ program.error(`Cache prune was not successful: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
414
+ }
415
+ });
236
416
  program
237
417
  .command('up')
238
418
  .description('start services(s)')
@@ -248,6 +428,8 @@ async function getProgram(environment, argv) {
248
428
  .addOption(new commander_1.Option('--cache <method>', 'caching method to compare')
249
429
  .default('checksum')
250
430
  .choices(['checksum', 'modify-date', 'none']))
431
+ .addOption(new commander_1.Option('--cache-read-only', `restore from cache backends but never push to them (or set ${read_only_1.CACHE_READ_ONLY_ENV}=1)`).default(false))
432
+ .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
251
433
  .action(async (options) => {
252
434
  try {
253
435
  const scope = parseWorkLabelScope(options);
@@ -258,6 +440,8 @@ async function getProgram(environment, argv) {
258
440
  workers: options.concurrency,
259
441
  logMode: options.log,
260
442
  daemon: options.daemon,
443
+ cacheReadOnly: (0, read_only_1.isCacheReadOnly)(options.cacheReadOnly, environment.processEnvs),
444
+ timeout: options.timeout ?? null,
261
445
  });
262
446
  if (!result.success) {
263
447
  program.error('Up was not successful', { exitCode: 1 });
@@ -311,22 +495,72 @@ async function getProgram(environment, argv) {
311
495
  .addOption(new commander_1.Option('--cache <method>', 'caching method to compare')
312
496
  .default('checksum')
313
497
  .choices(['checksum', 'modify-date', 'none']))
498
+ .addOption(new commander_1.Option('--no-summary', 'do not print the end-of-run summary'))
499
+ .addOption(new commander_1.Option('--summary-json', 'emit the end-of-run summary as JSON').default(false))
500
+ .addOption(new commander_1.Option('--explain', 'print the cache-miss cause when a task rebuilds').default(false))
501
+ .addOption(new commander_1.Option('--dry-run', 'print the execution plan with predicted cache hits/misses without running').default(false))
502
+ .addOption(new commander_1.Option('--cache-read-only', `restore from cache backends but never push to them (or set ${read_only_1.CACHE_READ_ONLY_ENV}=1)`).default(false))
503
+ .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
504
+ .addOption(new commander_1.Option('--no-skip-deps', 'run dependencies even when every task needing them is a cache hit'))
314
505
  .action(async (task, options) => {
315
506
  try {
316
- const cli = await createCli(fileName, environment, task ? { taskName: task, environmentName: options.env } : parseWorkLabelScope(options));
507
+ // For machine-readable JSON, suppress the human progress logger (which
508
+ // also writes to stdout) so the only thing on stdout is the JSON.
509
+ const runEnvironment = options.summaryJson
510
+ ? { ...environment, stdout: (0, empty_writable_1.emptyWritable)(), console: (0, log_1.consoleContext)((0, empty_writable_1.emptyWritable)()) }
511
+ : environment;
512
+ const cli = await createCli(fileName, runEnvironment, task ? { taskName: task, environmentName: options.env } : parseWorkLabelScope(options));
317
513
  if (cli.tasks().length === 0) {
318
514
  program.error('No tasks found', { exitCode: 127 });
319
515
  return;
320
516
  }
517
+ if (options.dryRun) {
518
+ const plan = await cli.dryRun({ cacheDefault: options.cache });
519
+ if (plan.cycle) {
520
+ program.error(`task cycle detected ${plan.cycle.join(' -> ')}`, { exitCode: 1 });
521
+ return;
522
+ }
523
+ (0, dry_run_1.printDryRun)(environment, plan);
524
+ return;
525
+ }
526
+ const runStart = Date.now();
321
527
  const result = await cli.runExec({
322
528
  cacheDefault: options.cache,
323
529
  watch: options.watch,
324
530
  workers: options.concurrency,
325
531
  logMode: options.log,
532
+ explain: options.explain,
533
+ cacheReadOnly: (0, read_only_1.isCacheReadOnly)(options.cacheReadOnly, environment.processEnvs),
534
+ timeout: options.timeout ?? null,
535
+ skipDeps: options.skipDeps !== false,
326
536
  });
537
+ // Reporting only: the summary never changes the exit code or behavior.
538
+ if (!options.watch) {
539
+ const summary = (0, run_summary_1.summarizeRun)(result.state, Date.now() - runStart);
540
+ if (options.summaryJson) {
541
+ environment.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
542
+ }
543
+ else if (options.summary !== false) {
544
+ (0, run_summary_1.printRunSummary)(environment, summary);
545
+ }
546
+ }
327
547
  if (!result.success) {
328
548
  program.error('Execution was not successful', { exitCode: 1 });
329
549
  }
550
+ // Declared retention keeps local caches bounded without anyone having
551
+ // to remember `cache prune`. Never on remotes, never in read-only mode.
552
+ if (!options.watch && !(0, read_only_1.isCacheReadOnly)(options.cacheReadOnly, environment.processEnvs)) {
553
+ try {
554
+ for (const { cacheName, plan } of await cli.autoPrune()) {
555
+ if (plan.evict.length > 0) {
556
+ runEnvironment.console.info(`pruned ${plan.evict.length} old ${plan.evict.length === 1 ? 'entry' : 'entries'} from cache "${cacheName}"`);
557
+ }
558
+ }
559
+ }
560
+ catch (e) {
561
+ runEnvironment.console.warn(`automatic cache prune failed: ${(0, log_1.getErrorMessage)(e)}`);
562
+ }
563
+ }
330
564
  }
331
565
  catch (e) {
332
566
  if (e instanceof commander_1.CommanderError) {
@@ -8,6 +8,7 @@ const labels_schema_1 = require("./labels-schema");
8
8
  const cache_schema_1 = require("./cache-schema");
9
9
  const build_file_task_command_schema_1 = require("./build-file-task-command-schema");
10
10
  const build_file_volume_schema_1 = require("./build-file-volume-schema");
11
+ const duration_schema_1 = require("./duration-schema");
11
12
  exports.buildFileBaseTaskSchema = (0, zod_1.object)({
12
13
  deps: (0, zod_1.array)((0, zod_1.string)()).optional(),
13
14
  needs: (0, zod_1.array)(build_file_need_schema_1.buildFileNeedSchema).optional(),
@@ -23,5 +24,8 @@ exports.buildFileBaseTaskSchema = (0, zod_1.object)({
23
24
  // A task that watches its own sources and restarts itself (e.g. `ng serve`,
24
25
  // `tsc -w`). In watch mode hammerkit then does not watch/restart it itself.
25
26
  continuous: (0, zod_1.boolean)().optional(),
27
+ // Maximum execution time, e.g. `30s`, `10m`, `1h30m`. The task fails when it
28
+ // runs longer. Does not affect the cache key (ADR-0002).
29
+ timeout: duration_schema_1.durationSchema.optional(),
26
30
  });
27
31
  //# sourceMappingURL=build-file-base-task-schema.js.map
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.cacheSchema = exports.taskCacheRefSchema = exports.buildFileCacheSchema = exports.cacheBackendSchema = exports.cacheBackendS3Schema = exports.cacheBackendLocalSchema = exports.cacheMethodSchema = void 0;
3
+ exports.cacheSchema = exports.taskCacheRefSchema = exports.buildFileCacheSchema = exports.cacheRetentionSchema = exports.cacheBackendSchema = exports.cacheBackendRegistrySchema = exports.cacheBackendS3Schema = exports.cacheBackendLocalSchema = exports.cacheMethodSchema = void 0;
4
4
  const zod_1 = require("zod");
5
+ const duration_schema_1 = require("./duration-schema");
6
+ const size_schema_1 = require("./size-schema");
5
7
  exports.cacheMethodSchema = (0, zod_1.union)([(0, zod_1.literal)('checksum'), (0, zod_1.literal)('modify-date'), (0, zod_1.literal)('none')]);
6
8
  exports.cacheBackendLocalSchema = (0, zod_1.object)({
7
9
  type: (0, zod_1.literal)('local'),
@@ -15,10 +17,27 @@ exports.cacheBackendS3Schema = (0, zod_1.object)({
15
17
  prefix: (0, zod_1.string)().optional(),
16
18
  forcePathStyle: zod_1.z.boolean().optional(),
17
19
  }).strict();
18
- exports.cacheBackendSchema = (0, zod_1.union)([exports.cacheBackendLocalSchema, exports.cacheBackendS3Schema]);
20
+ exports.cacheBackendRegistrySchema = (0, zod_1.object)({
21
+ type: (0, zod_1.literal)('registry'),
22
+ // repository to store entries in, e.g. `ghcr.io/org/hammerkit-cache`
23
+ repository: (0, zod_1.string)(),
24
+ // talk plain http (defaults to true only for localhost registries)
25
+ insecure: zod_1.z.boolean().optional(),
26
+ }).strict();
27
+ exports.cacheBackendSchema = (0, zod_1.union)([exports.cacheBackendLocalSchema, exports.cacheBackendS3Schema, exports.cacheBackendRegistrySchema]);
28
+ exports.cacheRetentionSchema = (0, zod_1.object)({
29
+ // evict entries not used within this long, e.g. `30d`
30
+ maxAge: duration_schema_1.durationSchema.optional(),
31
+ // evict least recently used entries until the cache is at most this big, e.g. `5Gi`
32
+ maxSize: size_schema_1.sizeSchema.optional(),
33
+ // keep only the newest N versions per task
34
+ keepPerTask: zod_1.z.number().int().positive().optional(),
35
+ }).strict();
19
36
  exports.buildFileCacheSchema = (0, zod_1.object)({
20
37
  method: exports.cacheMethodSchema,
21
38
  backend: exports.cacheBackendSchema,
39
+ // applied by `cache prune`, and automatically after a run for local caches
40
+ retention: exports.cacheRetentionSchema.optional(),
22
41
  }).strict();
23
42
  exports.taskCacheRefSchema = (0, zod_1.union)([
24
43
  exports.cacheMethodSchema,
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.durationSchema = void 0;
4
+ const zod_1 = require("zod");
5
+ const units_1 = require("../utils/units");
6
+ function isDuration(value) {
7
+ try {
8
+ (0, units_1.parseDuration)(value);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ exports.durationSchema = (0, zod_1.string)().refine(isDuration, (value) => ({
16
+ message: `invalid duration "${value}", expected e.g. 30s, 5m, 1h30m or 30d`,
17
+ }));
18
+ //# sourceMappingURL=duration-schema.js.map
@@ -8,6 +8,7 @@ const read_env_file_1 = require("../parser/read-env-file");
8
8
  const label_values_1 = require("../executer/label-values");
9
9
  const merge_environment_variables_1 = require("../environment/merge-environment-variables");
10
10
  const resolve_cache_1 = require("../cache/resolve-cache");
11
+ const project_root_1 = require("../planner/project-root");
11
12
  function combinePath(namePrefix, name) {
12
13
  return namePrefix.length > 0 ? `${namePrefix}:${name}` : name;
13
14
  }
@@ -20,6 +21,7 @@ async function parseReferences(ctx, mainScope, environment) {
20
21
  envFiles: {},
21
22
  environments: {},
22
23
  caches: {},
24
+ projectRoot: await (0, project_root_1.findProjectRoot)(mainScope.cwd, environment),
23
25
  };
24
26
  for (const file of Object.values(ctx.files)) {
25
27
  if (!reference.envFiles[file.cwd]) {
@@ -97,6 +99,7 @@ function applySchemaExtension(reference, task) {
97
99
  applySchemaExtension(reference, extend);
98
100
  task.schema.shell = task.schema.shell ?? extend.schema.shell;
99
101
  task.schema.cache = task.schema.cache ?? extend.schema.cache;
102
+ task.schema.timeout = task.schema.timeout ?? extend.schema.timeout;
100
103
  task.schema.cmds = extendArray(task.schema.cmds, extend.schema.cmds);
101
104
  task.schema.src = extendArray(task.schema.src, extend.schema.src);
102
105
  task.schema.generates = extendArray(task.schema.generates, extend.schema.generates);
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sizeSchema = void 0;
4
+ const zod_1 = require("zod");
5
+ const units_1 = require("../utils/units");
6
+ function isSize(value) {
7
+ try {
8
+ (0, units_1.parseSize)(value);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ exports.sizeSchema = (0, zod_1.string)().refine(isSize, (value) => ({
16
+ message: `invalid size "${value}", expected e.g. 500Mi, 5Gi or 2G`,
17
+ }));
18
+ //# sourceMappingURL=size-schema.js.map
@@ -17,8 +17,20 @@ function defaultEnvironment(context) {
17
17
  }
18
18
  exports.defaultEnvironment = defaultEnvironment;
19
19
  function getWorkContext(context, scope, environment) {
20
- const workTree = { services: {}, environment: defaultEnvironment(context), tasks: {} };
21
- const filteredWorkTree = { services: {}, environment: defaultEnvironment(context), tasks: {} };
20
+ const workTree = {
21
+ services: {},
22
+ environment: defaultEnvironment(context),
23
+ tasks: {},
24
+ caches: context.caches,
25
+ };
26
+ const filteredWorkTree = {
27
+ services: {},
28
+ environment: defaultEnvironment(context),
29
+ tasks: {},
30
+ caches: context.caches,
31
+ };
32
+ const requested = [];
33
+ filteredWorkTree.requested = requested;
22
34
  for (const [envName, env] of Object.entries(context.environments)) {
23
35
  if (envName === scope.environmentName) {
24
36
  filteredWorkTree.environment = (0, append_work_environment_1.appendWorkEnvironment)(env);
@@ -28,6 +40,7 @@ function getWorkContext(context, scope, environment) {
28
40
  for (const task of Object.values(context.tasks)) {
29
41
  const item = (0, append_work_task_1.appendWorkTask)(workTree, task.cwd, task, environment, context);
30
42
  if (matchesTaskWorkScope(item, scope)) {
43
+ requested.push(item.name);
31
44
  applyTask(filteredWorkTree, item);
32
45
  }
33
46
  }
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatSize = exports.parseSize = exports.formatDuration = exports.parseDuration = void 0;
4
+ const DURATION_UNITS = {
5
+ ms: 1,
6
+ s: 1000,
7
+ m: 60000,
8
+ h: 3600000,
9
+ d: 86400000,
10
+ };
11
+ // Parse a duration such as `500ms`, `30s`, `5m`, `1h30m` or `30d` into
12
+ // milliseconds. Every component needs a unit, and the total must be positive.
13
+ function parseDuration(value) {
14
+ const pattern = /(\d+(?:\.\d+)?)(ms|s|m|h|d)/y;
15
+ let total = 0;
16
+ let offset = 0;
17
+ while (offset < value.length) {
18
+ pattern.lastIndex = offset;
19
+ const match = pattern.exec(value);
20
+ if (!match) {
21
+ throw new Error(`invalid duration "${value}", expected e.g. 30s, 5m, 1h30m or 30d`);
22
+ }
23
+ total += parseFloat(match[1]) * DURATION_UNITS[match[2]];
24
+ offset = pattern.lastIndex;
25
+ }
26
+ if (total <= 0) {
27
+ throw new Error(`invalid duration "${value}", expected e.g. 30s, 5m, 1h30m or 30d`);
28
+ }
29
+ return Math.round(total);
30
+ }
31
+ exports.parseDuration = parseDuration;
32
+ function formatDuration(ms) {
33
+ if (ms < 1000) {
34
+ return `${ms}ms`;
35
+ }
36
+ let remaining = Math.round(ms / 1000);
37
+ const parts = [];
38
+ for (const [unit, seconds] of [
39
+ ['d', 86400],
40
+ ['h', 3600],
41
+ ['m', 60],
42
+ ['s', 1],
43
+ ]) {
44
+ if (remaining >= seconds) {
45
+ parts.push(`${Math.floor(remaining / seconds)}${unit}`);
46
+ remaining %= seconds;
47
+ }
48
+ }
49
+ return parts.join('');
50
+ }
51
+ exports.formatDuration = formatDuration;
52
+ const SIZE_UNITS = {
53
+ '': 1,
54
+ K: 1000,
55
+ M: 1000 ** 2,
56
+ G: 1000 ** 3,
57
+ T: 1000 ** 4,
58
+ Ki: 1024,
59
+ Mi: 1024 ** 2,
60
+ Gi: 1024 ** 3,
61
+ Ti: 1024 ** 4,
62
+ };
63
+ // Parse a size such as `500Mi`, `5Gi` or `2G` (Kubernetes-style suffixes;
64
+ // binary `Ki/Mi/Gi/Ti`, decimal `K/M/G/T`, none for bytes) into bytes.
65
+ function parseSize(value) {
66
+ const match = /^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti|K|M|G|T)?$/.exec(value);
67
+ if (!match) {
68
+ throw new Error(`invalid size "${value}", expected e.g. 500Mi, 5Gi or 2G`);
69
+ }
70
+ return Math.round(parseFloat(match[1]) * SIZE_UNITS[match[2] ?? '']);
71
+ }
72
+ exports.parseSize = parseSize;
73
+ function formatSize(bytes) {
74
+ for (const [unit, factor] of [
75
+ ['Ti', 1024 ** 4],
76
+ ['Gi', 1024 ** 3],
77
+ ['Mi', 1024 ** 2],
78
+ ['Ki', 1024],
79
+ ]) {
80
+ if (bytes >= factor) {
81
+ return `${(bytes / factor).toFixed(1).replace(/\.0$/, '')}${unit}`;
82
+ }
83
+ }
84
+ return `${bytes}B`;
85
+ }
86
+ exports.formatSize = formatSize;
87
+ //# sourceMappingURL=units.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.6.0",
4
+ "version": "1.7.0",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",
@@ -14,6 +14,13 @@
14
14
  ],
15
15
  "homepage": "https://no0dles.gitbook.io/hammerkit/",
16
16
  "bin": "dist/index.js",
17
+ "files": [
18
+ "dist",
19
+ "!dist/**/*.spec.*",
20
+ "!dist/testing",
21
+ "!dist/**/*.map",
22
+ "build.schema.json"
23
+ ],
17
24
  "bugs": {
18
25
  "url": "https://github.com/no0dles/hammerkit/issues"
19
26
  },
@@ -34,6 +41,7 @@
34
41
  "build": "tsc -b tsconfig.json",
35
42
  "test": "vitest run",
36
43
  "test:integration": "vitest run --mode integration",
44
+ "test:mutation": "stryker run",
37
45
  "format": "prettier -w src/** && eslint . --fix --ext .ts",
38
46
  "lint": "eslint . --ext .ts"
39
47
  },
@@ -51,6 +59,8 @@
51
59
  "zod": "^3.22.4"
52
60
  },
53
61
  "devDependencies": {
62
+ "@stryker-mutator/core": "^10.0.0",
63
+ "@stryker-mutator/vitest-runner": "^10.0.0",
54
64
  "@types/dockerode": "^3.3.23",
55
65
  "@types/minimatch": "^5.1.2",
56
66
  "@types/node": "^24.0.0",
@@ -60,6 +70,7 @@
60
70
  "@vitest/coverage-istanbul": "^2.1.8",
61
71
  "@yao-pkg/pkg": "^6.20.0",
62
72
  "eslint": "^8.56.0",
73
+ "fast-check": "^4.10.2",
63
74
  "prettier": "^3.1.1",
64
75
  "typescript": "^5.3.3",
65
76
  "vitest": "^2.1.8",
@@ -1,74 +0,0 @@
1
- # Feedback log
2
-
3
- Durable record of corrections and preferences the user has given AI agents working
4
- in this repo. The point of this file is that the user should **not have to repeat
5
- himself**.
6
-
7
- **Contract — every agent, every session:**
8
-
9
- 1. **Read this file before you start working.** Apply every rule here without being
10
- reminded.
11
- 2. **Don't re-ask anything already answered here.** If a rule covers the situation,
12
- follow it instead of asking.
13
- 3. **When the user corrects you or states a preference, append an entry here
14
- immediately** — same session, before moving on — so it is captured for next
15
- time. Keep entries short: a rule, **Why**, **How to apply**, and a date.
16
-
17
- Related: the companion `do-the-work` skill explains the behavior; this file is the
18
- running record.
19
-
20
- ---
21
-
22
- ## Rules
23
-
24
- ### Do the work — don't propose avoiding it _(2026-05-25)_
25
-
26
- Skipping, disabling, excluding, `.skip` / `it.skip` / `xfail`, commenting-out,
27
- deleting, loosening/weakening an assertion, or stopping early is **never** an
28
- acceptable way to resolve a failing test or a red build. Fix the root cause.
29
-
30
- - **Why:** The user asked for the thing to be *fixed*. Avoiding the work is not a
31
- smaller version of the job — it's not doing the job, and it hides a regression.
32
- Avoidance being the agent's default reflex is what the user finds worrying.
33
- - **How to apply:** Diagnose the underlying cause (timing, environment,
34
- file-watching, resource leaks, ordering, a real bug) and fix it so the check
35
- passes legitimately. Only ever skip/disable something if the user *explicitly*
36
- asks for it in that conversation.
37
-
38
- ### "Fix CI" means fix the failure, not remove the test _(2026-05-25)_
39
-
40
- When the task is to get CI green, the deliverable is a green run achieved by
41
- fixing what is broken — not by removing, skipping, or muting the failing check.
42
-
43
- - **Why:** Fixing CI *is* the requested work. Removing the test makes CI green
44
- while leaving the actual problem in place.
45
- - **How to apply:** Use the `fix-ci` skill loop (diagnose → fix → verify →
46
- commit → push → watch). If a failure is genuinely container/k8s/infra-only and
47
- cannot be reproduced on this host, say so explicitly and rely on CI to confirm
48
- the fix — that is not the same as skipping the test.
49
-
50
- ### Prefer configurable timeouts over skips or huge fixed values _(2026-05-25)_
51
-
52
- When a test hangs or flakes, make the timeout configurable (e.g. via env var)
53
- rather than skipping the test or hardcoding a giant timeout.
54
-
55
- - **Why:** CI is slower than local; a configurable budget lets local fail fast
56
- (surfacing hangs quickly) while CI gets the time it needs. Reducing flake is
57
- always desirable; skipping never is.
58
- - **How to apply:** Drive the timeout from an env var with a sensible local
59
- default and a longer CI value; fix the underlying cause of the hang/flake.
60
-
61
- ### Ask implementation questions freely; never ask permission to avoid work _(2026-05-25)_
62
-
63
- Questions about *how* to implement something — design, approach, ambiguity,
64
- trade-offs — are welcome and encouraged. Questions that are really requests to
65
- *not do* the assigned work are not.
66
-
67
- - **Why:** The user repeatedly got asked variants of "can I skip/stop/disable
68
- this?" instead of getting the work done. He doesn't want to keep saying "no, do
69
- it."
70
- - **How to apply:** If a question's effect is to avoid the work (skip a test,
71
- drop a requirement, leave the failure), don't ask it — do the work. Only
72
- escalate when *genuinely* blocked: missing access/credentials, a product
73
- decision only the user can make, or contradictory requirements — and even then,
74
- propose the fix path, not the avoidance.