@hone-ai/cli 1.18.0 → 1.20.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.
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+ /**
3
+ * schedule-cron.js — HC-054f cron-hour analysis for `hone schedule install`.
4
+ *
5
+ * Adopters who install a workflow with a cron like "0 2 * * *" (2 AM
6
+ * UTC) almost always want Night Shift mode (--overnight) because a
7
+ * 2 AM run is by definition an overnight batch. Pre-HC-054f the CLI
8
+ * never passed --overnight to queue-stories so the scheduled workflow
9
+ * silently ran without auto-approve / longer timeouts / 25-story cap.
10
+ *
11
+ * **Timezone — load-bearing**: GitHub Actions cron runs in **UTC** by
12
+ * default. This helper analyzes the cron's hour field AS UTC. It does
13
+ * NOT translate to the adopter's local timezone. Adopters in non-UTC
14
+ * timezones who want overnight semantics in THEIR LOCAL TIME must
15
+ * write the cron's UTC equivalent (e.g., Tokyo adopter wanting
16
+ * 2 AM JST should write `"0 17 * * *"` because 2 AM JST = 17 UTC the
17
+ * previous day). Pass-1 review HIGH #2 caught the original docstring
18
+ * + roadmap incorrectly described the window as "local time" — this
19
+ * was misleading.
20
+ *
21
+ * HC-054f: when the cron expression's hour (UTC) is a LITERAL single
22
+ * value within the 18:00–05:59 UTC window, default --overnight=true.
23
+ * Ambiguous hour fields (wildcard, range, step) and out-of-window
24
+ * hours (06:00–17:59 UTC) DO NOT auto-default — adopter must
25
+ * explicitly opt in via --overnight yes.
26
+ *
27
+ * Window choice: [18, 23] ∪ [0, 5] UTC. 06:00 UTC itself is NOT
28
+ * overnight — a `"0 6 * * *"` cron is a morning queue, not an
29
+ * overnight one. The 5:59:59 UTC boundary matches "wake before 6 AM
30
+ * UTC" intuition. Adopters in distant timezones (JST, AEST, PST etc.)
31
+ * may need to translate; the CLI surfaces the UTC interpretation in
32
+ * its decision-reporting output so the adopter sees what the helper
33
+ * actually decided.
34
+ *
35
+ * Pure helper. No I/O. Unit-tested at cli/lib/schedule-cron.test.js.
36
+ */
37
+
38
+ const OVERNIGHT_HOURS = Object.freeze(
39
+ Array.from({ length: 24 }, (_, h) => h).filter(h => h >= 18 || h < 6),
40
+ );
41
+
42
+ /**
43
+ * Analyze a cron expression to determine whether the schedule falls
44
+ * in the overnight window.
45
+ *
46
+ * @param {string} cronExpr — 5-field cron expression
47
+ * @returns {{ overnight: boolean, hour: number|null, reason: string }}
48
+ * overnight: true ONLY when the hour field is a literal single value
49
+ * within OVERNIGHT_HOURS.
50
+ * hour: the parsed hour (0-23) when literal; null for ambiguous.
51
+ * reason: short human-readable explanation for the CLI output.
52
+ */
53
+ function analyzeCron(cronExpr) {
54
+ if (typeof cronExpr !== 'string') {
55
+ return { overnight: false, hour: null, reason: 'cron must be a string' };
56
+ }
57
+ const fields = cronExpr.trim().split(/\s+/);
58
+ if (fields.length !== 5) {
59
+ return { overnight: false, hour: null, reason: 'cron must have exactly 5 fields' };
60
+ }
61
+ const hourField = fields[1];
62
+
63
+ // Wildcard, range, step, list — all ambiguous (adopter could be
64
+ // running it during the day too). Don't auto-default; require
65
+ // explicit --overnight yes.
66
+ if (!/^\d+$/.test(hourField)) {
67
+ return { overnight: false, hour: null,
68
+ reason: `hour field "${hourField}" is not a literal — pass --overnight yes to enable Night Shift` };
69
+ }
70
+
71
+ const hour = parseInt(hourField, 10);
72
+ if (hour < 0 || hour > 23) {
73
+ return { overnight: false, hour: null,
74
+ reason: `hour field "${hourField}" out of range 0-23` };
75
+ }
76
+
77
+ if (OVERNIGHT_HOURS.includes(hour)) {
78
+ return { overnight: true, hour,
79
+ reason: `hour ${hour} UTC falls in the overnight window (18:00-05:59 UTC)` };
80
+ }
81
+ return { overnight: false, hour,
82
+ reason: `hour ${hour} UTC is daytime (06:00-17:59 UTC); pass --overnight yes to enable Night Shift anyway` };
83
+ }
84
+
85
+ // Accepted aliases for explicit modes. Lowercased before comparison so
86
+ // `--overnight YES` and `--overnight Yes` both work — pass-1 review
87
+ // HIGH caught that the original case-sensitive implementation silently
88
+ // fell through to auto on uppercase aliases, contradicting adopter
89
+ // intent without any signal.
90
+ const YES_ALIASES = Object.freeze(new Set(['yes', 'true', '1', 'on', 'enable', 'enabled']));
91
+ const NO_ALIASES = Object.freeze(new Set(['no', 'false', '0', 'off', 'disable', 'disabled']));
92
+ const AUTO_ALIASES = Object.freeze(new Set(['auto', 'default', '']));
93
+
94
+ /**
95
+ * Check whether a `--overnight <mode>` value is a recognized mode (any
96
+ * known alias, case-insensitive). Caller uses this to validate the
97
+ * adopter's input UPFRONT and exit non-zero with a helpful message
98
+ * rather than silently falling back to auto on a typo.
99
+ *
100
+ * @param {string} mode
101
+ * @returns {boolean}
102
+ */
103
+ function isKnownOvernightMode(mode) {
104
+ if (typeof mode !== 'string') return false;
105
+ const m = mode.trim().toLowerCase();
106
+ return YES_ALIASES.has(m) || NO_ALIASES.has(m) || AUTO_ALIASES.has(m);
107
+ }
108
+
109
+ /**
110
+ * Resolve the final overnight setting given the adopter's explicit
111
+ * mode (`auto`|`yes`|`no`, case-insensitive, with aliases) and the
112
+ * cron analysis.
113
+ *
114
+ * UNKNOWN modes are treated as `auto` per the historical fallback,
115
+ * BUT the CLI should reject unknown modes upfront via
116
+ * `isKnownOvernightMode` — this fallback exists so library callers
117
+ * that didn't validate get safe behavior.
118
+ *
119
+ * @param {string} mode — value of --overnight (auto|yes|no + aliases)
120
+ * @param {ReturnType<typeof analyzeCron>} analysis
121
+ * @returns {{ overnight: boolean, source: 'explicit-yes'|'explicit-no'|'auto-detect-yes'|'auto-detect-no' }}
122
+ */
123
+ function resolveOvernight(mode, analysis) {
124
+ const m = typeof mode === 'string' ? mode.trim().toLowerCase() : '';
125
+ if (YES_ALIASES.has(m)) return { overnight: true, source: 'explicit-yes' };
126
+ if (NO_ALIASES.has(m)) return { overnight: false, source: 'explicit-no' };
127
+ return {
128
+ overnight: analysis.overnight,
129
+ source: analysis.overnight ? 'auto-detect-yes' : 'auto-detect-no',
130
+ };
131
+ }
132
+
133
+ module.exports = {
134
+ OVERNIGHT_HOURS,
135
+ YES_ALIASES,
136
+ NO_ALIASES,
137
+ AUTO_ALIASES,
138
+ analyzeCron,
139
+ resolveOvernight,
140
+ isKnownOvernightMode,
141
+ };