@ethlete/agent-rules 0.1.0-next.17 → 0.1.0-next.19

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 (55) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +14 -2
  3. package/content/hooks/subagent-model-policy.py +5 -3
  4. package/content/rules/app-styling.md +6 -1
  5. package/content/rules/subagent-models.md +7 -7
  6. package/content/skills/design-exploration/SKILL.md +3 -1
  7. package/content/skills/query/SKILL.md +10 -2
  8. package/content/skills/sdk-update/SKILL.md +5 -3
  9. package/content/skills/timetrack/SKILL.md +97 -27
  10. package/migrations/app-styling-utilities.md +44 -95
  11. package/migrations/list-state-query-form.md +12 -95
  12. package/migrations/nx-layout.md +6 -0
  13. package/migrations/sdk-components-over-hand-built-ui.md +16 -58
  14. package/migrations/search-query-field.md +12 -5
  15. package/migrations.json +5 -5
  16. package/package.json +1 -1
  17. package/src/lib/frontmatter.js +32 -5
  18. package/src/lib/frontmatter.js.map +1 -1
  19. package/src/lib/git-flow/parse.js +38 -12
  20. package/src/lib/git-flow/parse.js.map +1 -1
  21. package/src/lib/git-flow-repair.js +12 -1
  22. package/src/lib/git-flow-repair.js.map +1 -1
  23. package/src/lib/git.js +8 -2
  24. package/src/lib/git.js.map +1 -1
  25. package/src/lib/gitlab.js +11 -1
  26. package/src/lib/gitlab.js.map +1 -1
  27. package/src/lib/migrate.js +1 -1
  28. package/src/lib/migrate.js.map +1 -1
  29. package/src/lib/output-style.js +11 -3
  30. package/src/lib/output-style.js.map +1 -1
  31. package/src/lib/owned-paths.js +8 -2
  32. package/src/lib/owned-paths.js.map +1 -1
  33. package/src/lib/plan.js +20 -4
  34. package/src/lib/plan.js.map +1 -1
  35. package/src/lib/render.d.ts +4 -0
  36. package/src/lib/render.js +8 -2
  37. package/src/lib/render.js.map +1 -1
  38. package/src/lib/sync.js +3 -0
  39. package/src/lib/sync.js.map +1 -1
  40. package/src/lib/targets/codex.js +1 -1
  41. package/src/lib/targets/codex.js.map +1 -1
  42. package/src/lib/targets/copilot.js +1 -1
  43. package/src/lib/targets/copilot.js.map +1 -1
  44. package/src/lib/targets/git-hooks.js +2 -1
  45. package/src/lib/targets/git-hooks.js.map +1 -1
  46. package/src/lib/targets/hooks-shared.js +1 -1
  47. package/src/lib/targets/hooks-shared.js.map +1 -1
  48. package/src/lib/timetrack-command.js +316 -72
  49. package/src/lib/timetrack-command.js.map +1 -1
  50. package/src/lib/timetrack-snapshot.d.ts +55 -0
  51. package/src/lib/timetrack-snapshot.js +120 -0
  52. package/src/lib/timetrack-snapshot.js.map +1 -0
  53. package/src/lib/timetrack.d.ts +125 -23
  54. package/src/lib/timetrack.js +53 -17
  55. package/src/lib/timetrack.js.map +1 -1
@@ -6,6 +6,7 @@ const fs_1 = require("fs");
6
6
  const path_1 = require("path");
7
7
  const timetrack_1 = require("./timetrack");
8
8
  const plain_text_1 = require("./plain-text");
9
+ const timetrack_snapshot_1 = require("./timetrack-snapshot");
9
10
  const git_1 = require("./git");
10
11
  const FLAGS_WITH_VALUE = [
11
12
  '--root',
@@ -30,33 +31,66 @@ const FLAGS_WITH_VALUE = [
30
31
  '--paths',
31
32
  '--claim',
32
33
  '--author',
34
+ '--plan',
35
+ '--delete',
36
+ '--repo',
37
+ '--name',
38
+ '--rename',
39
+ '--merge',
40
+ '--into',
41
+ '--compare',
33
42
  ];
34
43
  /** Every human-readable line, with anything a terminal would act on printed rather than obeyed. */
35
44
  const say = (line) => console.log((0, plain_text_1.plain)(line));
36
45
  const positionalArgs = (args) => args.filter((entry, index) => { var _a; return !entry.startsWith('--') && !FLAGS_WITH_VALUE.includes((_a = args[index - 1]) !== null && _a !== void 0 ? _a : ''); });
37
46
  const flagValue = (args, flag) => {
38
47
  const index = args.indexOf(flag);
39
- return index === -1 ? undefined : args[index + 1];
48
+ if (index === -1)
49
+ return undefined;
50
+ const value = args[index + 1];
51
+ if (value === undefined || value.startsWith('--'))
52
+ throw new Error(`${flag} needs a value.`);
53
+ return value;
40
54
  };
41
55
  const numberFlag = (args, flag) => {
42
56
  const raw = flagValue(args, flag);
43
57
  if (raw === undefined)
44
58
  return undefined;
45
- const value = Number(raw);
59
+ const value = raw.trim() ? Number(raw) : Number.NaN;
46
60
  if (!Number.isFinite(value))
47
61
  throw new Error(`${flag} takes a number, not ${raw}.`);
48
62
  return value;
49
63
  };
50
- /** `--at` takes anything `Date` reads, so an agent may pass an ISO instant or leave it out for now. */
51
- const instantFlag = (args) => {
52
- const raw = flagValue(args, '--at');
53
- if (!raw)
54
- return Date.now();
55
- const at = new Date(raw);
64
+ const countFlag = (args, flag) => {
65
+ const value = numberFlag(args, flag);
66
+ if (value !== undefined && (!Number.isInteger(value) || value <= 0)) {
67
+ throw new Error(`${flag} takes a whole number above zero, not ${value}.`);
68
+ }
69
+ return value;
70
+ };
71
+ const LOCAL_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
72
+ const LOCAL_CLOCK = /^(\d{1,2}):(\d{2})$/;
73
+ /**
74
+ * Reads an instant flag. A date alone is local midnight and a clock alone is that time today, where
75
+ * `Date` would read the first as UTC and refuse the second; anything else goes to `Date` as it is.
76
+ */
77
+ const instantOf = (flag, raw) => {
78
+ const date = LOCAL_DATE.exec(raw);
79
+ const clock = LOCAL_CLOCK.exec(raw);
80
+ const today = new Date();
81
+ const at = date
82
+ ? new Date(Number(date[1]), Number(date[2]) - 1, Number(date[3]))
83
+ : clock
84
+ ? new Date(today.getFullYear(), today.getMonth(), today.getDate(), Number(clock[1]), Number(clock[2]))
85
+ : new Date(raw);
56
86
  if (Number.isNaN(at.getTime()))
57
- throw new Error(`--at takes a date, not ${raw}.`);
87
+ throw new Error(`${flag} takes a date, not ${raw}.`);
58
88
  return at.getTime();
59
89
  };
90
+ const instantFlag = (args) => {
91
+ const raw = flagValue(args, '--at');
92
+ return raw ? instantOf('--at', raw) : Date.now();
93
+ };
60
94
  const issueLine = (issue) => [
61
95
  issue.key,
62
96
  issue.issueType,
@@ -208,6 +242,63 @@ const tempoDayLines = (worklogs) => {
208
242
  return `${day} ${hours(total)} ${parts.join(' ')}`;
209
243
  });
210
244
  };
245
+ const syncWriteLine = (write) => {
246
+ var _a;
247
+ return [
248
+ write.kind.padEnd(6),
249
+ (_a = write.issueKey) !== null && _a !== void 0 ? _a : write.proposalId,
250
+ `${clock(write.fromMs)}-${clock(write.fromMs + write.durationMs)}`,
251
+ `${minutesOf(write.durationMs)}m`,
252
+ write.reason,
253
+ ...(write.tempoWorklogId ? [`worklog ${write.tempoWorklogId}`] : []),
254
+ write.description || '(no description)',
255
+ ...(write.blocked ? [`HELD BACK: ${write.blocked}`] : []),
256
+ ].join(' ');
257
+ };
258
+ /** What a sync of a day would write, row by row, and what it leaves alone. */
259
+ const syncPlanLines = (plan) => {
260
+ const count = (kind) => plan.writes.filter((write) => write.kind === kind).length;
261
+ const blocked = plan.writes.filter((write) => write.blocked).length;
262
+ return [
263
+ plan.writes.length
264
+ ? `${plan.day} plan ${plan.planHash} ${count('create')} create, ${count('update')} update, ${count('delete')} delete`
265
+ : `${plan.day} nothing to write`,
266
+ ...plan.writes.map((write) => ` ${syncWriteLine(write)}`),
267
+ `${plan.unchanged} unchanged, ${plan.skipped} skipped (still awaiting review)`,
268
+ ...(blocked ? [`${blocked} row(s) held back: Tempo would refuse them until they are fixed on the day.`] : []),
269
+ ...(plan.unresolvedKeys.length
270
+ ? [`Jira does not know ${plan.unresolvedKeys.join(', ')}, so nothing is written for those rows.`]
271
+ : []),
272
+ ...(plan.coveredMs
273
+ ? [`${minutesOf(plan.coveredMs)}m of the day is already logged by foreign worklogs, so it is left out.`]
274
+ : []),
275
+ `Foreign worklogs (${plan.foreign.length}), never touched`,
276
+ ...plan.foreign.map((worklog) => { var _a; return ` ${(_a = worklog.issueKey) !== null && _a !== void 0 ? _a : `#${worklog.issueId}`} ${clock(worklog.fromMs)} ${minutesOf(worklog.durationMs)}m ${worklog.description || '(no description)'}`; }),
277
+ ];
278
+ };
279
+ /** Every row a write attempted, and what the user has to do about the ones that did not land. */
280
+ const syncRunLines = (run) => [
281
+ ...run.rows.map((row) => {
282
+ var _a;
283
+ return [
284
+ row.status.padEnd(7),
285
+ row.kind.padEnd(6),
286
+ (_a = row.issueKey) !== null && _a !== void 0 ? _a : row.proposalId,
287
+ ...(row.tempoWorklogId ? [`worklog ${row.tempoWorklogId}`] : []),
288
+ ...(row.detail ? [row.detail] : []),
289
+ ].join(' ');
290
+ }),
291
+ ...(run.retryCount
292
+ ? [
293
+ `${run.retryCount} row(s) did not land. Retry them on the Sync page, which retries this run's own plan: a new plan read straight after a write can miss what Tempo just took.`,
294
+ ]
295
+ : []),
296
+ ...(run.unrecorded
297
+ ? [
298
+ `Written, but not recorded: ${run.unrecorded}. Tempo holds these worklogs and the app no longer owns them. Delete them in Tempo before writing this day again, or the time is logged twice.`,
299
+ ]
300
+ : []),
301
+ ];
211
302
  const meetingMs = (events) => events
212
303
  .filter((event) => !event.allDay && event.response !== 'declined')
213
304
  .reduce((sum, event) => sum + event.endMs - event.startMs, 0);
@@ -231,13 +322,6 @@ const calendarDayLines = (events) => {
231
322
  ...dayEvents.map((event) => ` ${calendarEventLine(event)}`),
232
323
  ]);
233
324
  };
234
- /** `--from` and `--to` take anything `Date` reads, so a caller may pass an ISO instant or a clock. */
235
- const instantOf = (flag, raw) => {
236
- const at = new Date(raw);
237
- if (Number.isNaN(at.getTime()))
238
- throw new Error(`${flag} takes a date, not ${raw}.`);
239
- return at.getTime();
240
- };
241
325
  /** Which edits the flags name. `--from` with `--to` is the one pair that names a single change. */
242
326
  const namedEdits = (argv) => {
243
327
  const named = argv.includes('--from') || argv.includes('--to') ? ['--from/--to'] : [];
@@ -305,7 +389,7 @@ const writeExport = (options) => {
305
389
  (0, fs_1.unlinkSync)(path);
306
390
  const handle = (0, fs_1.openSync)(path, 'wx', 0o600);
307
391
  try {
308
- (0, fs_1.writeSync)(handle, data);
392
+ (0, fs_1.writeFileSync)(handle, data);
309
393
  }
310
394
  finally {
311
395
  (0, fs_1.closeSync)(handle);
@@ -317,6 +401,49 @@ const printed = (value, json) => {
317
401
  console.log(JSON.stringify(value, null, 2));
318
402
  return 0;
319
403
  };
404
+ /** Every write waits in the app for the user's press, so what a write prints is where to look next. */
405
+ const queuedLines = (queued, what) => [
406
+ `Queued in Timetrack for the user's approval: ${what}.`,
407
+ `Nothing is written until they approve it. Read the outcome with: timetrack approval ${queued.approvalId}`,
408
+ ];
409
+ const printedQueued = (options) => {
410
+ if (!options.json)
411
+ queuedLines(options.queued, options.what).forEach(say);
412
+ return printed(options.queued, options.json);
413
+ };
414
+ const isSyncWithRun = (value) => typeof value === 'object' && value !== null && 'run' in value && typeof value.run === 'object';
415
+ const UNDECIDED = {
416
+ queued: "still waits for the user's approval in Timetrack.",
417
+ rejected: 'the user rejected it. Nothing was written.',
418
+ expired: 'expired at the end of the day it was asked on. Nothing was written. Ask again.',
419
+ };
420
+ const approvalLines = (found) => {
421
+ if (found.status !== 'approved')
422
+ return [`${found.approvalId} ${UNDECIDED[found.status]}`];
423
+ if (found.error !== undefined)
424
+ return [`${found.approvalId} approved, but it failed: ${found.error}`];
425
+ const unrecorded = unrecordedOf(found.result);
426
+ return [
427
+ `${found.approvalId} approved and carried out.`,
428
+ ...(isSyncWithRun(found.result) ? syncRunLines(found.result.run) : ['Pass --json for what it answered.']),
429
+ ...(unrecorded && !isSyncWithRun(found.result)
430
+ ? [`Carried out, but the app's ledger did not follow: ${unrecorded}. A later sync may undo it.`]
431
+ : []),
432
+ ];
433
+ };
434
+ const unrecordedOf = (value) => typeof value === 'object' && value !== null && 'unrecorded' in value && typeof value.unrecorded === 'string'
435
+ ? value.unrecorded
436
+ : undefined;
437
+ /** Non-zero for anything that did not land in full, so a caller polling in a script can tell. */
438
+ const approvalExitCode = (found) => {
439
+ if (found.status === 'queued')
440
+ return 0;
441
+ if (found.status !== 'approved' || found.error !== undefined)
442
+ return 1;
443
+ if (unrecordedOf(found.result))
444
+ return 1;
445
+ return isSyncWithRun(found.result) && (found.result.run.retryCount || found.result.run.unrecorded) ? 1 : 0;
446
+ };
320
447
  /**
321
448
  * Cuts a placeholder that covered a whole checkout into one per directory its commits worked in.
322
449
  *
@@ -352,16 +479,8 @@ const splitStandIn = (options) => tslib_1.__awaiter(void 0, void 0, void 0, func
352
479
  const claim = flagValue(argv, '--claim');
353
480
  const apply = argv.includes('--force');
354
481
  const projectRoots = (0, git_1.projectRootsOf)(checkout);
355
- const answer = yield (0, timetrack_1.timetrackSplitStandIn)({
356
- id,
357
- branch,
358
- repoPath,
359
- commits,
360
- projectRoots,
361
- paths,
362
- claim,
363
- apply,
364
- });
482
+ const request = { id, branch, repoPath, commits, projectRoots, paths, claim };
483
+ const answer = yield (0, timetrack_1.timetrackSplitStandIn)(request);
365
484
  if (!json) {
366
485
  say(`${standIn.name} ${standIn.days.length} day(s), branch ${branch}`);
367
486
  say(`Directories the commits name (${answer.candidates.length})`);
@@ -369,10 +488,7 @@ const splitStandIn = (options) => tslib_1.__awaiter(void 0, void 0, void 0, func
369
488
  if (!answer.pieces.length) {
370
489
  say('None of them is an automatic grain. Pick the pieces with --paths <dir>,<dir>.');
371
490
  }
372
- else if (apply) {
373
- say(`Split into ${answer.pieces.length}, and the rule that named it rewritten one per directory.`);
374
- }
375
- else {
491
+ else if (!apply) {
376
492
  say(`Would write ${answer.pieces.map((piece) => piece.workPath).join(', ')}. Pass --force to carry it out.`);
377
493
  }
378
494
  if (claim) {
@@ -387,11 +503,38 @@ const splitStandIn = (options) => tslib_1.__awaiter(void 0, void 0, void 0, func
387
503
  say('Name them in the day review, or re-run with --claim <dir> to give them a directory.');
388
504
  }
389
505
  }
506
+ if (apply && answer.pieces.length) {
507
+ const queued = yield (0, timetrack_1.timetrackApplyStandInSplit)(request);
508
+ return printedQueued({ queued, what: `split ${standIn.name} into ${answer.pieces.length}`, json });
509
+ }
390
510
  return printed(answer, json);
391
511
  });
512
+ const daysBetween = (from, to) => {
513
+ const days = [];
514
+ for (let day = from; day <= to && days.length < 92; day = shiftDay(day, 1))
515
+ days.push(day);
516
+ return days;
517
+ };
518
+ const gitState = (root) => {
519
+ try {
520
+ const uncommitted = (0, git_1.git)({ root, args: ['status', '--porcelain'] })
521
+ .split('\n')
522
+ .filter(Boolean)
523
+ .map((line) => line.slice(3));
524
+ return { head: (0, git_1.git)({ root, args: ['rev-parse', 'HEAD'] }), uncommitted };
525
+ }
526
+ catch (_a) {
527
+ return {};
528
+ }
529
+ };
530
+ const takeSnapshot = (options) => tslib_1.__awaiter(void 0, void 0, void 0, function* () {
531
+ return (Object.assign(Object.assign({ takenAt: new Date().toISOString() }, gitState(options.root)), { days: yield options.days.reduce((done, day) => tslib_1.__awaiter(void 0, void 0, void 0, function* () { return [...(yield done), (0, timetrack_snapshot_1.snapshotDayOf)(yield (0, timetrack_1.timetrackDayRows)(day))]; }), Promise.resolve([])) }));
532
+ });
533
+ const defaultSnapshotPath = () => (0, path_1.join)((0, path_1.dirname)((0, timetrack_1.timetrackDiscoveryPath)()), 'snapshots', `${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
392
534
  const USAGE = `ethlete-agents timetrack — ask the running Timetrack app about Jira
393
535
 
394
536
  The app holds this machine's Jira credentials, so no repository needs a token of its own.
537
+ Every write waits in the app until the user approves it there, and prints an approval id.
395
538
 
396
539
  timetrack status Whether the app is reachable, and which projects it holds
397
540
  timetrack instance The instance's own levels and its branch-subject candidates
@@ -403,6 +546,10 @@ The app holds this machine's Jira credentials, so no repository needs a token of
403
546
  Add a row nothing observed to the day it belongs to
404
547
  timetrack day [YYYY-MM-DD] The evidence a day holds, which the encrypted store hides otherwise
405
548
  timetrack rows [YYYY-MM-DD] The rows the day drew, with the ids an edit names them by
549
+ timetrack snapshot [from] [to] [--out <file>]
550
+ Record the rows of the last 7 days (default) before a change
551
+ timetrack snapshot --compare <file>
552
+ Print every row the change altered since; exit 1 when any did
406
553
  timetrack edit <row-id> … Change one row of a day: its times, its name, its note or its state
407
554
  timetrack rules The rules that name a day's work: attribution, project links, apps
408
555
  timetrack standins The names the user gave work Jira does not hold yet, and their age
@@ -410,6 +557,8 @@ The app holds this machine's Jira credentials, so no repository needs a token of
410
557
  Delete one placeholder, and the rule that named it
411
558
  timetrack standins --rename <id> --name <text>
412
559
  Give one placeholder another name, keeping its days and its rules
560
+ timetrack standins --merge <id> --into <id>
561
+ Fold one open placeholder into another, with its days and its rules
413
562
  timetrack standins --split <id> [--repo <dir>] [--paths <dir>,<dir>] [--claim <dir>] [--author <email>]
414
563
  [--force]
415
564
  Cut one that covered a whole checkout into one per directory
@@ -417,8 +566,14 @@ The app holds this machine's Jira credentials, so no repository needs a token of
417
566
  The account's own Tempo worklogs per day (default: the last 7 days)
418
567
  timetrack calendar [from] [to]
419
568
  The watched calendars' events per day (default: the last 7 days)
569
+ timetrack sync <YYYY-MM-DD> What a Tempo sync of the day would write, as the Sync page plans it
570
+ timetrack sync <YYYY-MM-DD> --write --plan <hash>
571
+ Write that plan to Tempo, once the user confirmed its rows
572
+ timetrack worklog --delete <id> --day <YYYY-MM-DD>
573
+ Delete one of the account's own Tempo worklogs, once the user asked for it
420
574
  timetrack naming [YYYY-MM-DD] Which checkouts the day offers a name for, and why the rest do not
421
575
  timetrack resync [path…] Read the agent session logs of checkouts again, after they got a link
576
+ timetrack approval <id> Where a queued write stands, and what it answered once approved
422
577
 
423
578
  Options for search
424
579
  --project <KEY> Search this project instead of the picked ones
@@ -446,6 +601,11 @@ Options for day
446
601
  Options for resync
447
602
  --replace Overwrite what the store holds for those sessions, after a parser fix
448
603
 
604
+ Options for sync
605
+ --write Queue the write of the plan. Refused without --plan; the app refuses it on
606
+ approval when the plan changed since
607
+ --plan <hash> The plan hash the read printed, for the rows the user confirmed
608
+
449
609
  Options for edit — pass exactly one change
450
610
  --day <YYYY-MM-DD> The day the row is on (default: today)
451
611
  --from <date> --to <date>
@@ -468,7 +628,7 @@ Options everywhere
468
628
  * one per checkout.
469
629
  */
470
630
  const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0, function* () {
471
- var _a, _b, _c;
631
+ var _a, _b, _c, _d, _e, _f;
472
632
  const { root, argv } = options;
473
633
  const [subcommand, value] = positionalArgs(argv);
474
634
  const json = argv.includes('--json');
@@ -507,7 +667,7 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
507
667
  text: value,
508
668
  projectKey: flagValue(argv, '--project'),
509
669
  assignedToMe: argv.includes('--mine'),
510
- limit: numberFlag(argv, '--limit'),
670
+ limit: countFlag(argv, '--limit'),
511
671
  });
512
672
  if (!json) {
513
673
  if (issues.length === 0)
@@ -517,7 +677,7 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
517
677
  return printed(issues, json);
518
678
  }
519
679
  if (subcommand === 'project') {
520
- const found = yield (0, timetrack_1.timetrackRepoProject)(value !== null && value !== void 0 ? value : root);
680
+ const found = yield (0, timetrack_1.timetrackRepoProject)((0, path_1.resolve)(root, value !== null && value !== void 0 ? value : '.'));
521
681
  if (!json) {
522
682
  const where = found.inherited ? ' (from a directory above it)' : '';
523
683
  if (found.private)
@@ -536,7 +696,7 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
536
696
  const summary = flagValue(argv, '--summary');
537
697
  if (!summary)
538
698
  throw new Error('Pass --summary "…".');
539
- const issue = yield (0, timetrack_1.timetrackCreateIssue)({
699
+ const queued = yield (0, timetrack_1.timetrackCreateIssue)({
540
700
  summary,
541
701
  description: flagValue(argv, '--description'),
542
702
  projectKey: flagValue(argv, '--project'),
@@ -544,9 +704,7 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
544
704
  parentKey: flagValue(argv, '--parent'),
545
705
  subject: flagValue(argv, '--subject'),
546
706
  });
547
- if (!json)
548
- say(`${issue.key} ${summary}`);
549
- return printed(issue, json);
707
+ return printedQueued({ queued, what: `file the issue ${summary}`, json });
550
708
  }
551
709
  if (subcommand === 'log') {
552
710
  const issueKey = flagValue(argv, '--issue');
@@ -555,26 +713,27 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
555
713
  throw new Error('Pass --issue <KEY>.');
556
714
  if (!minutes || minutes <= 0)
557
715
  throw new Error('Pass --minutes <n> above zero.');
558
- const worklog = yield (0, timetrack_1.timetrackAddWorklog)({
716
+ const queued = yield (0, timetrack_1.timetrackAddWorklog)({
559
717
  issueKey,
560
718
  description: flagValue(argv, '--description'),
561
719
  fromMs: instantFlag(argv),
562
720
  durationMs: Math.round(minutes * 60000),
563
721
  });
564
- if (!json) {
565
- say(`${worklog.issueKey} ${minutes}m on ${worklog.day}`);
566
- say('It is a row on the day, not a Tempo entry — review the day in Timetrack, then sync it.');
567
- }
568
- return printed(worklog, json);
722
+ if (!json)
723
+ say('Once approved it is a row on the day, not a Tempo entry — the day still needs a sync.');
724
+ return printedQueued({ queued, what: `a ${minutes}m row for ${issueKey}`, json });
569
725
  }
570
726
  if (subcommand === 'day') {
571
727
  const day = value !== null && value !== void 0 ? value : today();
572
728
  if (!DAY.test(day))
573
729
  throw new Error(`Pass a day as YYYY-MM-DD, not ${day}.`);
574
730
  const found = yield (0, timetrack_1.timetrackDayEvents)(day);
575
- const out = flagValue(argv, '--out');
576
- if (out) {
731
+ const outFlag = flagValue(argv, '--out');
732
+ if (outFlag) {
733
+ const out = (0, path_1.resolve)(root, outFlag);
577
734
  writeExport({ path: out, data: JSON.stringify(found), overwrite: argv.includes('--overwrite') });
735
+ if (json)
736
+ return printed({ day: found.day, events: found.events.length, out }, json);
578
737
  say(`${found.day} ${found.events.length} events written to ${out}, readable by you alone`);
579
738
  say('It holds the day as it was observed: window titles, paths and messages. Delete it when you are done.');
580
739
  return 0;
@@ -599,19 +758,43 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
599
758
  }
600
759
  return printed(found, json);
601
760
  }
761
+ if (subcommand === 'snapshot') {
762
+ const compare = flagValue(argv, '--compare');
763
+ if (compare) {
764
+ const recorded = JSON.parse((0, fs_1.readFileSync)((0, path_1.resolve)(root, compare), 'utf8'));
765
+ const now = yield takeSnapshot({ days: recorded.days.map((entry) => entry.day), root });
766
+ const diff = (0, timetrack_snapshot_1.diffSnapshots)(recorded, now);
767
+ if (json)
768
+ console.log(JSON.stringify(diff, null, 2));
769
+ else
770
+ (0, timetrack_snapshot_1.diffLines)(diff).forEach((line) => say(line));
771
+ return diff.changedRows || diff.days.length ? 1 : 0;
772
+ }
773
+ const named = positionalArgs(argv).slice(1);
774
+ const to = (_a = named[1]) !== null && _a !== void 0 ? _a : today();
775
+ const from = (_b = named[0]) !== null && _b !== void 0 ? _b : shiftDay(to, -6);
776
+ if (!DAY.test(from))
777
+ throw new Error(`Pass a day as YYYY-MM-DD, not ${from}.`);
778
+ if (!DAY.test(to))
779
+ throw new Error(`Pass a day as YYYY-MM-DD, not ${to}.`);
780
+ const path = (0, path_1.resolve)(root, (_c = flagValue(argv, '--out')) !== null && _c !== void 0 ? _c : defaultSnapshotPath());
781
+ const recorded = yield takeSnapshot({ days: daysBetween(from, to), root });
782
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(path), { recursive: true });
783
+ (0, fs_1.writeFileSync)(path, JSON.stringify(recorded, null, 2), { mode: 0o600 });
784
+ if (json)
785
+ console.log(JSON.stringify({ path, days: recorded.days.length }));
786
+ else
787
+ say(`Recorded ${recorded.days.length} day(s), ${recorded.days.reduce((sum, day) => sum + day.rows.length, 0)} row(s) in ${path}`);
788
+ return 0;
789
+ }
602
790
  if (subcommand === 'edit') {
603
- const day = (_a = flagValue(argv, '--day')) !== null && _a !== void 0 ? _a : today();
791
+ const day = (_d = flagValue(argv, '--day')) !== null && _d !== void 0 ? _d : today();
604
792
  if (!value)
605
793
  throw new Error('Pass the row id to edit, as `timetrack rows` prints it.');
606
794
  if (!DAY.test(day))
607
795
  throw new Error(`Pass a day as YYYY-MM-DD, not ${day}.`);
608
- const edited = yield (0, timetrack_1.timetrackEditDay)({ day, edits: [editOf({ argv, rowId: value })] });
609
- if (!json) {
610
- say(`${edited.day} ${edited.applied} of 1 edit landed`);
611
- edited.rows.forEach((row) => say(` ${rowLine(row)}`));
612
- edited.warnings.forEach((warning) => say(` ! ${warning.kind}: ${warning.detail}`));
613
- }
614
- return printed(edited, json);
796
+ const queued = yield (0, timetrack_1.timetrackEditDay)({ day, edits: [editOf({ argv, rowId: value })] });
797
+ return printedQueued({ queued, what: `an edit of row ${value} on ${day}`, json });
615
798
  }
616
799
  if (subcommand === 'rules') {
617
800
  const rules = yield (0, timetrack_1.timetrackRules)();
@@ -626,17 +809,14 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
626
809
  }
627
810
  if (subcommand === 'resync') {
628
811
  const named = positionalArgs(argv).slice(1);
629
- const resynced = yield (0, timetrack_1.timetrackResyncAgentSessions)((named.length ? named : ['.']).map((path) => (0, path_1.resolve)(root, path)), {
630
- replace: argv.includes('--replace'),
631
- });
632
- if (!json) {
633
- const how = resynced.replace ? ', replacing what it stored for them' : '';
634
- say(`Timetrack reads the agent sessions of ${resynced.paths.join(', ')} again${how}.`);
635
- }
636
- return printed(resynced, json);
812
+ const paths = (named.length ? named : ['.']).map((path) => (0, path_1.resolve)(root, path));
813
+ const replace = argv.includes('--replace');
814
+ const queued = yield (0, timetrack_1.timetrackResyncAgentSessions)(paths, { replace });
815
+ const how = replace ? ', replacing what it stored for them' : '';
816
+ return printedQueued({ queued, what: `read the agent sessions of ${paths.join(', ')} again${how}`, json });
637
817
  }
638
818
  if (subcommand === 'worklogs') {
639
- const to = (_b = positionalArgs(argv)[2]) !== null && _b !== void 0 ? _b : today();
819
+ const to = (_e = positionalArgs(argv)[2]) !== null && _e !== void 0 ? _e : today();
640
820
  const from = value !== null && value !== void 0 ? value : shiftDay(to, -6);
641
821
  if (!DAY.test(from))
642
822
  throw new Error(`Pass a day as YYYY-MM-DD, not ${from}.`);
@@ -650,8 +830,51 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
650
830
  }
651
831
  return printed(found, json);
652
832
  }
833
+ if (subcommand === 'worklog') {
834
+ if (argv.includes('--help')) {
835
+ say(USAGE);
836
+ return 0;
837
+ }
838
+ const worklogId = flagValue(argv, '--delete');
839
+ const day = flagValue(argv, '--day');
840
+ if (!worklogId || !/^\d+$/.test(worklogId))
841
+ throw new Error('Pass the Tempo worklog id to delete with --delete <id>.');
842
+ if (!day || !DAY.test(day))
843
+ throw new Error('Pass the day the worklog is on with --day <YYYY-MM-DD>.');
844
+ const queued = yield (0, timetrack_1.timetrackTempoDelete)({ day, worklogId });
845
+ return printedQueued({ queued, what: `delete Tempo worklog ${worklogId} on ${day}`, json });
846
+ }
847
+ if (subcommand === 'sync') {
848
+ if (!value || !DAY.test(value))
849
+ throw new Error('Pass the day to sync as YYYY-MM-DD.');
850
+ const planHash = flagValue(argv, '--plan');
851
+ const write = argv.includes('--write');
852
+ if (write && !planHash) {
853
+ say(`A write names the plan the user confirmed. Read it with \`timetrack sync ${value}\`, show the rows,`);
854
+ say('and pass the hash it prints with --plan once they agree.');
855
+ return 1;
856
+ }
857
+ if (planHash && !write) {
858
+ say('--plan only means something with --write. Nothing was written.');
859
+ return 1;
860
+ }
861
+ if (write && planHash) {
862
+ const queued = yield (0, timetrack_1.timetrackTempoSyncWrite)({ day: value, planHash });
863
+ if (!json)
864
+ say('The app checks the plan again on approval, and refuses it if the day changed since.');
865
+ return printedQueued({ queued, what: `write plan ${planHash} of ${value} to Tempo`, json });
866
+ }
867
+ const found = yield (0, timetrack_1.timetrackTempoSync)(value);
868
+ if (!json) {
869
+ syncPlanLines(found).forEach(say);
870
+ if (found.writes.length) {
871
+ say(`Once the user has confirmed these rows: timetrack sync ${found.day} --write --plan ${found.planHash}`);
872
+ }
873
+ }
874
+ return printed(found, json);
875
+ }
653
876
  if (subcommand === 'calendar') {
654
- const to = (_c = positionalArgs(argv)[2]) !== null && _c !== void 0 ? _c : today();
877
+ const to = (_f = positionalArgs(argv)[2]) !== null && _f !== void 0 ? _f : today();
655
878
  const from = value !== null && value !== void 0 ? value : shiftDay(to, -6);
656
879
  if (!DAY.test(from))
657
880
  throw new Error(`Pass a day as YYYY-MM-DD, not ${from}.`);
@@ -665,7 +888,9 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
665
888
  return printed(found, json);
666
889
  }
667
890
  if (subcommand === 'naming') {
668
- const day = value && DAY.test(value) ? value : today();
891
+ const day = value !== null && value !== void 0 ? value : today();
892
+ if (!DAY.test(day))
893
+ throw new Error(`Pass a day as YYYY-MM-DD, not ${day}.`);
669
894
  const naming = yield (0, timetrack_1.timetrackNaming)(day);
670
895
  if (!json) {
671
896
  say(`${day} tempo ${naming.tempoReady ? 'configured' : 'not configured'}`);
@@ -679,6 +904,15 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
679
904
  }
680
905
  return printed(naming, json);
681
906
  }
907
+ if (subcommand === 'approval') {
908
+ if (!value)
909
+ throw new Error('Pass the approval id a write printed, e.g. `timetrack approval <id>`.');
910
+ const found = yield (0, timetrack_1.timetrackApprovalStatus)(value);
911
+ if (!json)
912
+ approvalLines(found).forEach(say);
913
+ printed(found, json);
914
+ return approvalExitCode(found);
915
+ }
682
916
  if (subcommand === 'standins') {
683
917
  const split = flagValue(argv, '--split');
684
918
  if (split)
@@ -690,10 +924,18 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
690
924
  return 1;
691
925
  }
692
926
  if (rename && name) {
693
- const renamed = yield (0, timetrack_1.timetrackRenameStandIn)({ id: rename, name });
694
- if (!json)
695
- say(`Renamed ${rename} to ${name}.`);
696
- return printed(renamed, json);
927
+ const queued = yield (0, timetrack_1.timetrackRenameStandIn)({ id: rename, name });
928
+ return printedQueued({ queued, what: `rename ${rename} to ${name}`, json });
929
+ }
930
+ const merge = flagValue(argv, '--merge');
931
+ const into = flagValue(argv, '--into');
932
+ if (merge && !into) {
933
+ say(`A merge needs the placeholder it goes into. Pass --into <id> with --merge ${merge}.`);
934
+ return 1;
935
+ }
936
+ if (merge && into) {
937
+ const queued = yield (0, timetrack_1.timetrackMergeStandIn)({ id: merge, into });
938
+ return printedQueued({ queued, what: `merge ${merge} into ${into}`, json });
697
939
  }
698
940
  const remove = flagValue(argv, '--remove');
699
941
  const stranded = remove ? yield strandedDays(remove) : [];
@@ -703,14 +945,16 @@ const timetrackCommand = (options) => tslib_1.__awaiter(void 0, void 0, void 0,
703
945
  say('The app reopens a placeholder for today only. Pass --force if that is what you want.');
704
946
  return 1;
705
947
  }
706
- const standIns = remove ? yield (0, timetrack_1.timetrackRemoveStandIn)(remove) : yield (0, timetrack_1.timetrackStandIns)();
948
+ if (remove) {
949
+ const queued = yield (0, timetrack_1.timetrackRemoveStandIn)(remove);
950
+ return printedQueued({ queued, what: `delete ${remove} and the rule that named it`, json });
951
+ }
952
+ const standIns = yield (0, timetrack_1.timetrackStandIns)();
707
953
  const rules = json ? [] : (yield (0, timetrack_1.timetrackRules)()).attributionRules;
708
954
  const open = standIns
709
955
  .filter((standIn) => standIn.state === 'open')
710
956
  .sort((left, right) => left.createdAtMs - right.createdAtMs);
711
957
  if (!json) {
712
- if (remove)
713
- say(`Deleted ${remove}, and the rule that named it.`);
714
958
  say(`${open.length} open, ${standIns.length - open.length} resolved`);
715
959
  open.forEach((standIn) => say(` ${describeStandIn({ standIn, rules })}`));
716
960
  if (open.length)