@indigoai-us/hq-cli 5.273.0-alpha.0 → 5.274.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 (91) hide show
  1. package/CHANGELOG.md +65 -2
  2. package/README.md +32 -1
  3. package/dist/command-catalog.generated.d.ts +83 -536
  4. package/dist/command-catalog.generated.js +129 -714
  5. package/dist/command-registration-plan.d.ts +12 -0
  6. package/dist/command-registration-plan.js +2 -0
  7. package/dist/commands/cloud.js +40 -0
  8. package/dist/commands/core-checkpoint.js +3 -1
  9. package/dist/commands/core-command-catalog.d.ts +32 -0
  10. package/dist/commands/core-command-catalog.js +58 -0
  11. package/dist/commands/core-ontology.js +9 -4
  12. package/dist/commands/core.d.ts +4 -0
  13. package/dist/commands/core.js +79 -75
  14. package/dist/commands/daemon.d.ts +0 -3
  15. package/dist/commands/daemon.js +1 -173
  16. package/dist/commands/deploy-comments.d.ts +3 -0
  17. package/dist/commands/deploy-comments.js +159 -0
  18. package/dist/commands/lanes-checks.d.ts +0 -1
  19. package/dist/commands/lanes-checks.js +217 -54
  20. package/dist/commands/lanes-hold.js +40 -2
  21. package/dist/commands/lanes-message.js +98 -14
  22. package/dist/commands/lanes-move.d.ts +12 -0
  23. package/dist/commands/lanes-move.js +342 -0
  24. package/dist/commands/lanes-resume.js +3 -1
  25. package/dist/commands/lanes-shared.d.ts +39 -3
  26. package/dist/commands/lanes-shared.js +59 -0
  27. package/dist/commands/lanes.d.ts +3 -5
  28. package/dist/commands/lanes.js +206 -478
  29. package/dist/commands/report.js +2 -14
  30. package/dist/commands/sync-mode.d.ts +0 -11
  31. package/dist/commands/sync-mode.js +0 -30
  32. package/dist/fast-core.js +1 -1
  33. package/dist/{commands/scaffold-fast.d.ts → fast-scaffold.d.ts} +1 -1
  34. package/dist/{commands/scaffold-fast.js → fast-scaffold.js} +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/lib/checkpoint-ontology/apply.d.ts +8 -0
  38. package/dist/lib/checkpoint-ontology/apply.js +33 -0
  39. package/dist/lib/checkpoint-ontology/plan.d.ts +15 -0
  40. package/dist/lib/checkpoint-ontology/plan.js +15 -0
  41. package/dist/lib/daemon/catalog.d.ts +2 -18
  42. package/dist/lib/daemon/catalog.js +2 -18
  43. package/dist/lib/daemon/config.d.ts +0 -6
  44. package/dist/lib/daemon/config.js +2 -15
  45. package/dist/lib/daemon/requests.d.ts +0 -4
  46. package/dist/lib/daemon/requests.js +0 -2
  47. package/dist/lib/daemon/run.js +19 -78
  48. package/dist/lib/daemon/supervisor.d.ts +0 -8
  49. package/dist/lib/daemon/supervisor.js +1 -101
  50. package/dist/lib/deploy-api.d.ts +6 -0
  51. package/dist/lib/deploy-api.js +21 -0
  52. package/dist/lib/doctor/checks/scaffold-cli-floor.d.ts +7 -0
  53. package/dist/lib/doctor/checks/scaffold-cli-floor.js +73 -0
  54. package/dist/lib/doctor/fix/apply.d.ts +17 -2
  55. package/dist/lib/doctor/fix/apply.js +111 -57
  56. package/dist/lib/doctor/fix/json-output.d.ts +9 -1
  57. package/dist/lib/doctor/fix/json-output.js +11 -0
  58. package/dist/lib/doctor/registry.js +2 -0
  59. package/dist/lib/first-sync-next-step.d.ts +37 -0
  60. package/dist/lib/first-sync-next-step.js +189 -0
  61. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  62. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  63. package/dist/lib/lanes/hire-grant-dispatch.d.ts +6 -1
  64. package/dist/lib/lanes/hire-grant-dispatch.js +78 -3
  65. package/dist/lib/lanes/keep-alive.d.ts +3 -2
  66. package/dist/lib/lanes/keep-alive.js +2 -2
  67. package/dist/lib/lanes/merge-command.d.ts +1 -0
  68. package/dist/lib/lanes/merge-command.js +299 -33
  69. package/dist/lib/lanes/orphans.d.ts +7 -0
  70. package/dist/lib/lanes/orphans.js +39 -4
  71. package/dist/lib/lanes/tracker-store.d.ts +2 -18
  72. package/dist/lib/lanes/tracker-store.js +1 -172
  73. package/dist/lib/scaffold-cli-floor.d.ts +9 -0
  74. package/dist/lib/scaffold-cli-floor.js +50 -0
  75. package/dist/main.js +4 -1
  76. package/dist/utils/cli-telemetry.d.ts +7 -1
  77. package/dist/utils/cli-telemetry.js +84 -8
  78. package/dist/utils/contribution-table.d.ts +1 -1
  79. package/dist/utils/scaffold-fast-import-guard.d.ts +3 -0
  80. package/dist/utils/scaffold-fast-import-guard.js +28 -0
  81. package/dist/utils/scaffold-packlist-guard.d.ts +7 -0
  82. package/dist/utils/scaffold-packlist-guard.js +23 -0
  83. package/dist/utils/scaffold-sibling-references.d.ts +21 -0
  84. package/dist/utils/scaffold-sibling-references.js +156 -0
  85. package/package.json +2 -2
  86. package/dist/commands/lanes-import.d.ts +0 -14
  87. package/dist/commands/lanes-import.js +0 -1210
  88. package/dist/commands/lanes-tracker.d.ts +0 -18
  89. package/dist/commands/lanes-tracker.js +0 -452
  90. package/dist/lib/daemon/sync-runner.d.ts +0 -58
  91. package/dist/lib/daemon/sync-runner.js +0 -277
@@ -5,15 +5,20 @@ export interface HireGrantLaneSnapshot {
5
5
  state: LaneState;
6
6
  senior: SeniorRef;
7
7
  hire_grant?: number;
8
+ created_at?: string;
9
+ company?: LaneRecord["company"];
10
+ project_id?: string;
11
+ worker_profile?: string;
8
12
  }
9
13
  export interface OutpostHireGrantContext {
10
14
  schema_version: typeof HIRE_GRANT_CONTEXT_SCHEMA;
11
15
  enforcement_enabled: boolean;
12
16
  expected_senior: SeniorRef;
13
17
  hierarchy: HireGrantLaneSnapshot[];
18
+ superseded_message_caller_id?: string;
14
19
  }
15
20
  /** Encode only the owner-relevant hierarchy from the primary's complete store. */
16
- export declare function createOutpostHireGrantContext(target: LaneRecord, lanes: readonly LaneRecord[]): string;
21
+ export declare function createOutpostHireGrantContext(target: LaneRecord, lanes: readonly LaneRecord[], supersededMessageCallerId?: string): string;
17
22
  /** Decode and validate an owner grant context, binding it to the target lane. */
18
23
  export declare function parseOutpostHireGrantContext(encoded: string, targetLaneId: string): OutpostHireGrantContext;
19
24
  export declare function sameHireGrantSenior(a: SeniorRef, b: SeniorRef): boolean;
@@ -20,11 +20,17 @@ function snapshot(lane) {
20
20
  lane_id: lane.lane_id,
21
21
  state: lane.state,
22
22
  senior: copySenior(lane.senior),
23
+ created_at: lane.created_at,
24
+ ...(lane.company === undefined
25
+ ? {}
26
+ : { company: lane.company === null ? null : { ...lane.company } }),
27
+ ...(lane.project_id === undefined ? {} : { project_id: lane.project_id }),
28
+ ...(lane.worker_profile === undefined ? {} : { worker_profile: lane.worker_profile }),
23
29
  ...(lane.hire_grant === undefined ? {} : { hire_grant: lane.hire_grant }),
24
30
  };
25
31
  }
26
32
  /** Encode only the owner-relevant hierarchy from the primary's complete store. */
27
- export function createOutpostHireGrantContext(target, lanes) {
33
+ export function createOutpostHireGrantContext(target, lanes, supersededMessageCallerId) {
28
34
  const current = lanes.find((lane) => lane.lane_id === target.lane_id);
29
35
  if (!current || !sameSenior(current.senior, target.senior)) {
30
36
  throw new LanesError("hire_grant_hierarchy_unavailable", `Cannot build an authoritative hire-grant hierarchy for lane ${target.lane_id}`, { lane_id: target.lane_id });
@@ -45,6 +51,20 @@ export function createOutpostHireGrantContext(target, lanes) {
45
51
  for (const child of aliveDirectChildren(lanes, current.lane_id)) {
46
52
  relevant.set(child.lane_id, child);
47
53
  }
54
+ if (supersededMessageCallerId !== undefined) {
55
+ const messageCaller = lanes.find((lane) => lane.lane_id === supersededMessageCallerId);
56
+ const companyUid = current.company?.uid;
57
+ const projectId = current.project_id;
58
+ if (!messageCaller || !companyUid || !projectId ||
59
+ messageCaller.company?.uid !== companyUid || messageCaller.project_id !== projectId) {
60
+ throw new LanesError("hire_grant_message_caller_invalid", `Cannot include an unverified message caller ${supersededMessageCallerId} in lane ${target.lane_id}'s Outpost context`, { lane_id: target.lane_id, caller_lane_id: supersededMessageCallerId });
61
+ }
62
+ for (const candidate of lanes) {
63
+ if (candidate.company?.uid === companyUid && candidate.project_id === projectId) {
64
+ relevant.set(candidate.lane_id, candidate);
65
+ }
66
+ }
67
+ }
48
68
  const hierarchy = [...relevant.values()]
49
69
  .map(snapshot)
50
70
  .sort((a, b) => a.lane_id.localeCompare(b.lane_id));
@@ -56,6 +76,9 @@ export function createOutpostHireGrantContext(target, lanes) {
56
76
  enforcement_enabled: true,
57
77
  expected_senior: copySenior(current.senior),
58
78
  hierarchy,
79
+ ...(supersededMessageCallerId === undefined
80
+ ? {}
81
+ : { superseded_message_caller_id: supersededMessageCallerId }),
59
82
  };
60
83
  const encoded = Buffer.from(JSON.stringify(context), "utf8").toString("base64url");
61
84
  if (Buffer.byteLength(encoded, "utf8") > MAX_CONTEXT_BYTES) {
@@ -86,7 +109,9 @@ function parseLaneSnapshot(value) {
86
109
  if (value === null || typeof value !== "object" || Array.isArray(value))
87
110
  return null;
88
111
  const record = value;
89
- if (Object.keys(record).some((key) => !["lane_id", "state", "senior", "hire_grant"].includes(key)))
112
+ if (Object.keys(record).some((key) => ![
113
+ "lane_id", "state", "senior", "hire_grant", "created_at", "company", "project_id", "worker_profile",
114
+ ].includes(key)))
90
115
  return null;
91
116
  if (typeof record.lane_id !== "string" || !record.lane_id.trim())
92
117
  return null;
@@ -99,11 +124,44 @@ function parseLaneSnapshot(value) {
99
124
  (typeof record.hire_grant !== "number" || !Number.isSafeInteger(record.hire_grant) || record.hire_grant < 0)) {
100
125
  return null;
101
126
  }
127
+ if (record.created_at !== undefined &&
128
+ (typeof record.created_at !== "string" || !record.created_at.trim() || !Number.isFinite(Date.parse(record.created_at)))) {
129
+ return null;
130
+ }
131
+ if (record.project_id !== undefined &&
132
+ (typeof record.project_id !== "string" || !record.project_id.trim() || record.project_id.trim() !== record.project_id)) {
133
+ return null;
134
+ }
135
+ if (record.worker_profile !== undefined &&
136
+ (typeof record.worker_profile !== "string" || !record.worker_profile.trim())) {
137
+ return null;
138
+ }
139
+ let company;
140
+ if (Object.hasOwn(record, "company")) {
141
+ if (record.company === null) {
142
+ company = null;
143
+ }
144
+ else if (record.company !== null && typeof record.company === "object" && !Array.isArray(record.company)) {
145
+ const companyRecord = record.company;
146
+ if (Object.keys(companyRecord).some((key) => key !== "slug" && key !== "uid") ||
147
+ typeof companyRecord.slug !== "string" || !companyRecord.slug.trim() ||
148
+ typeof companyRecord.uid !== "string" || !companyRecord.uid.trim())
149
+ return null;
150
+ company = { slug: companyRecord.slug, uid: companyRecord.uid };
151
+ }
152
+ else {
153
+ return null;
154
+ }
155
+ }
102
156
  return {
103
157
  lane_id: record.lane_id,
104
158
  state: record.state,
105
159
  senior,
106
160
  ...(record.hire_grant === undefined ? {} : { hire_grant: record.hire_grant }),
161
+ ...(record.created_at === undefined ? {} : { created_at: record.created_at }),
162
+ ...(company === undefined ? {} : { company }),
163
+ ...(record.project_id === undefined ? {} : { project_id: record.project_id }),
164
+ ...(record.worker_profile === undefined ? {} : { worker_profile: record.worker_profile }),
107
165
  };
108
166
  }
109
167
  /** Decode and validate an owner grant context, binding it to the target lane. */
@@ -125,7 +183,7 @@ export function parseOutpostHireGrantContext(encoded, targetLaneId) {
125
183
  throw contextError("Outpost grant context must be an object");
126
184
  }
127
185
  const record = raw;
128
- if (Object.keys(record).some((key) => !["schema_version", "enforcement_enabled", "expected_senior", "hierarchy"].includes(key))) {
186
+ if (Object.keys(record).some((key) => !["schema_version", "enforcement_enabled", "expected_senior", "hierarchy", "superseded_message_caller_id"].includes(key))) {
129
187
  throw contextError("Outpost grant context contains unknown fields");
130
188
  }
131
189
  if (record.schema_version !== HIRE_GRANT_CONTEXT_SCHEMA ||
@@ -135,6 +193,11 @@ export function parseOutpostHireGrantContext(encoded, targetLaneId) {
135
193
  throw contextError("Outpost grant context has an unsupported or incomplete shape");
136
194
  }
137
195
  const expectedSenior = parseSenior(record.expected_senior);
196
+ const messageCallerId = record.superseded_message_caller_id;
197
+ if (messageCallerId !== undefined &&
198
+ (typeof messageCallerId !== "string" || !messageCallerId.trim() || messageCallerId.trim() !== messageCallerId)) {
199
+ throw contextError("Outpost grant context contains an invalid superseded-message caller");
200
+ }
138
201
  const hierarchy = record.hierarchy.map(parseLaneSnapshot);
139
202
  if (!expectedSenior || hierarchy.some((lane) => lane === null)) {
140
203
  throw contextError("Outpost grant context contains an invalid senior or lane snapshot");
@@ -152,11 +215,23 @@ export function parseOutpostHireGrantContext(encoded, targetLaneId) {
152
215
  (!expectedSenior.id || !byId.has(expectedSenior.id))) {
153
216
  throw contextError("Outpost grant context is missing the target's senior lane");
154
217
  }
218
+ if (messageCallerId !== undefined && !byId.has(messageCallerId)) {
219
+ throw contextError("Outpost grant context is missing its superseded-message caller lane");
220
+ }
221
+ if (messageCallerId !== undefined) {
222
+ const caller = byId.get(messageCallerId);
223
+ if (snapshots.some((lane) => lane.created_at === undefined || !Object.hasOwn(lane, "company")) ||
224
+ !target.company?.uid || !target.project_id ||
225
+ caller?.company?.uid !== target.company.uid || caller.project_id !== target.project_id) {
226
+ throw contextError("Outpost grant context is missing its superseded-message hierarchy metadata");
227
+ }
228
+ }
155
229
  return {
156
230
  schema_version: HIRE_GRANT_CONTEXT_SCHEMA,
157
231
  enforcement_enabled: record.enforcement_enabled,
158
232
  expected_senior: expectedSenior,
159
233
  hierarchy: snapshots,
234
+ ...(messageCallerId === undefined ? {} : { superseded_message_caller_id: messageCallerId }),
160
235
  };
161
236
  }
162
237
  export function sameHireGrantSenior(a, b) {
@@ -7,6 +7,7 @@ export declare const LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED = false;
7
7
  export declare const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
8
8
  export declare const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS: number;
9
9
  export declare const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS: number;
10
+ export type KeepAliveLane = Pick<LaneRecord, "lane_id" | "created_at" | "senior" | "company" | "project_id"> & Partial<Pick<LaneRecord, "mesh_company" | "worker_profile" | "hire_grant" | "provider_session_id" | "last_resume" | "state">>;
10
11
  export interface LaneTerminalResumeResult {
11
12
  lane: LaneRecord;
12
13
  resumeQueued: boolean;
@@ -40,7 +41,7 @@ export declare function retiredLaneRegistryPath(hqRoot: string): string;
40
41
  export declare function loadRetiredLaneIds(hqRoot: string): ReadonlySet<string>;
41
42
  /** IDs may be stored bare or with an owner-host suffix after an underscore. */
42
43
  export declare function isRetiredLaneId(laneId: string, retiredLaneIds: ReadonlySet<string>): boolean;
43
- export declare function keepAliveSupersededBy(lane: LaneRecord, lanes: readonly LaneRecord[], lineageAware?: boolean, companyForLane?: (lane: LaneRecord) => LaneRecord["company"]): LaneRecord | undefined;
44
- export declare function keepAliveExclusion(lane: LaneRecord, lanes: readonly LaneRecord[], retiredLaneIds: ReadonlySet<string>, lineageAware?: boolean, companyForLane?: (lane: LaneRecord) => LaneRecord["company"]): "retired" | "superseded" | undefined;
44
+ export declare function keepAliveSupersededBy<T extends KeepAliveLane>(lane: T, lanes: readonly T[], lineageAware?: boolean, companyForLane?: (lane: T) => T["company"]): T | undefined;
45
+ export declare function keepAliveExclusion<T extends KeepAliveLane>(lane: T, lanes: readonly T[], retiredLaneIds: ReadonlySet<string>, lineageAware?: boolean, companyForLane?: (lane: T) => T["company"]): "retired" | "superseded" | undefined;
45
46
  export declare function leadLaneKeepAliveBackoffMs(attempt: number): number;
46
47
  //# sourceMappingURL=keep-alive.d.ts.map
@@ -204,7 +204,7 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
204
204
  if (!Number.isFinite(createdAt))
205
205
  return undefined;
206
206
  const companyOf = companyForLane ?? ((candidate) => candidate.company);
207
- const laneCompany = companyOf(lane);
207
+ const laneCompany = companyOf(lane) ?? null;
208
208
  const lanesById = new Map(lanes.map((candidate) => [candidate.lane_id, candidate]));
209
209
  lanesById.set(lane.lane_id, lane);
210
210
  const sessionOwners = new Map();
@@ -223,7 +223,7 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
223
223
  return false;
224
224
  if (candidate.state === "cancelled" || candidate.state === "killed" || candidate.state === "failed")
225
225
  return false;
226
- const candidateCompany = companyOf(candidate);
226
+ const candidateCompany = companyOf(candidate) ?? null;
227
227
  const sameCompany = laneCompany === null
228
228
  ? candidateCompany === null
229
229
  : candidateCompany?.uid === laneCompany.uid;
@@ -8,6 +8,7 @@ export interface MergeCommandTarget {
8
8
  export interface MergeCommandParseResult {
9
9
  targets: MergeCommandTarget[];
10
10
  unresolved: boolean;
11
+ unparseableMerge: boolean;
11
12
  }
12
13
  export declare function canonicalRepo(raw: string): string;
13
14
  export declare function parseMergeCommands(command: string): MergeCommandParseResult;
@@ -1,4 +1,6 @@
1
+ import * as path from "node:path";
1
2
  import { LanesError } from "./errors.js";
3
+ const MAX_COMMAND_WRAPPER_DEPTH = 4;
2
4
  function fail(error, message, details = {}) {
3
5
  throw new LanesError(error, message, details);
4
6
  }
@@ -45,11 +47,13 @@ function splitCommandChain(command) {
45
47
  current += character;
46
48
  continue;
47
49
  }
48
- if (character === ";" || character === "|") {
50
+ if (character === ";" || character === "|" || character === "\n" || character === "\r") {
49
51
  segments.push(current.trim());
50
52
  current = "";
51
53
  if (character === "|" && command[index + 1] === "|")
52
54
  index += 1;
55
+ if (character === "\r" && command[index + 1] === "\n")
56
+ index += 1;
53
57
  continue;
54
58
  }
55
59
  if (character === "&" && command[index + 1] === "&") {
@@ -69,16 +73,26 @@ function splitCommandChain(command) {
69
73
  function shellWords(segment) {
70
74
  const words = [];
71
75
  let current = "";
76
+ let wordStarted = false;
72
77
  let quote = null;
73
78
  let escaped = false;
79
+ const finishWord = () => {
80
+ if (!wordStarted)
81
+ return;
82
+ words.push(current);
83
+ current = "";
84
+ wordStarted = false;
85
+ };
74
86
  for (const character of segment) {
75
87
  if (escaped) {
76
88
  current += character;
89
+ wordStarted = true;
77
90
  escaped = false;
78
91
  continue;
79
92
  }
80
93
  if (character === "\\" && quote !== "'") {
81
94
  escaped = true;
95
+ wordStarted = true;
82
96
  continue;
83
97
  }
84
98
  if (quote) {
@@ -86,24 +100,24 @@ function shellWords(segment) {
86
100
  quote = null;
87
101
  else
88
102
  current += character;
103
+ wordStarted = true;
89
104
  continue;
90
105
  }
91
106
  if (character === "'" || character === "\"") {
92
107
  quote = character;
108
+ wordStarted = true;
93
109
  continue;
94
110
  }
95
111
  if (/\s/.test(character)) {
96
- if (current)
97
- words.push(current);
98
- current = "";
112
+ finishWord();
99
113
  continue;
100
114
  }
101
115
  current += character;
116
+ wordStarted = true;
102
117
  }
103
- if (escaped)
104
- current += "\\";
105
- if (current)
106
- words.push(current);
118
+ if (quote !== null || escaped)
119
+ return fail("repo_hold_command_invalid", "Command has an unclosed quote or escape");
120
+ finishWord();
107
121
  return words;
108
122
  }
109
123
  function optionRepo(words) {
@@ -163,10 +177,8 @@ function skipEnvOptions(words, start) {
163
177
  }
164
178
  if (word === "--")
165
179
  return index + 1;
166
- if (word.startsWith("-")) {
167
- index += 1;
168
- continue;
169
- }
180
+ if (word.startsWith("-"))
181
+ return -1;
170
182
  break;
171
183
  }
172
184
  return index;
@@ -191,32 +203,223 @@ function skipTimeoutOptions(words, start) {
191
203
  index += 1;
192
204
  continue;
193
205
  }
194
- if (word.startsWith("-")) {
195
- index += 1;
196
- continue;
197
- }
206
+ if (word.startsWith("-"))
207
+ return -1;
198
208
  break;
199
209
  }
200
- return Math.min(index + 1, words.length);
210
+ const duration = words[index];
211
+ if (!duration || !/^(?:\d+(?:\.\d*)?|\.\d+)(?:s|m|h|d)?$/.test(duration))
212
+ return -1;
213
+ index += 1;
214
+ if (words[index] === "--")
215
+ index += 1;
216
+ return index < words.length ? index : -1;
201
217
  }
202
- function unwrapCommand(words) {
203
- let index = 0;
218
+ const SHELL_SHORT_OPTIONS = new Set(["a", "b", "B", "C", "e", "E", "f", "h", "H", "i", "k", "l", "m", "n", "p", "P", "r", "s", "t", "T", "u", "v", "x"]);
219
+ const SHELL_LONG_OPTIONS = new Set([
220
+ "--interactive", "--login", "--noprofile", "--norc", "--posix", "--restricted",
221
+ "--verbose", "--noediting", "--debugger",
222
+ ]);
223
+ const SHELL_LONG_VALUE_OPTIONS = new Set(["--rcfile", "--init-file", "--emulate"]);
224
+ function shellCommandWrapper(words) {
225
+ let index = 1;
204
226
  while (index < words.length) {
205
- if (SHELL_ASSIGNMENT.test(words[index] ?? "")) {
227
+ const option = words[index] ?? "";
228
+ if (option === "--")
229
+ return null;
230
+ if (SHELL_LONG_VALUE_OPTIONS.has(option)) {
231
+ if (!words[index + 1])
232
+ return { kind: "invalid" };
233
+ index += 2;
234
+ continue;
235
+ }
236
+ if ([...SHELL_LONG_VALUE_OPTIONS].some((name) => option.startsWith(`${name}=`))) {
206
237
  index += 1;
207
238
  continue;
208
239
  }
209
- if (words[index] === "env") {
210
- index = skipEnvOptions(words, index);
240
+ if (SHELL_LONG_OPTIONS.has(option)) {
241
+ index += 1;
211
242
  continue;
212
243
  }
213
- if (words[index] === "timeout") {
214
- index = skipTimeoutOptions(words, index);
244
+ if (option.startsWith("--"))
245
+ return { kind: "invalid" };
246
+ if ((option.startsWith("-") || option.startsWith("+")) && option.length > 1) {
247
+ const flags = option.slice(1);
248
+ let consumedOptionValue = false;
249
+ for (let flagIndex = 0; flagIndex < flags.length; flagIndex += 1) {
250
+ const flag = flags[flagIndex] ?? "";
251
+ if (flag === "c") {
252
+ const script = words[index + 1];
253
+ return script === undefined ? { kind: "invalid" } : { kind: "script", script };
254
+ }
255
+ if (flag === "o" || flag === "O") {
256
+ if (flagIndex + 1 < flags.length) {
257
+ index += 1;
258
+ consumedOptionValue = true;
259
+ break;
260
+ }
261
+ if (!words[index + 1])
262
+ return { kind: "invalid" };
263
+ index += 2;
264
+ consumedOptionValue = true;
265
+ break;
266
+ }
267
+ if (!SHELL_SHORT_OPTIONS.has(flag))
268
+ return { kind: "invalid" };
269
+ }
270
+ if (!consumedOptionValue)
271
+ index += 1;
215
272
  continue;
216
273
  }
217
- break;
274
+ return null;
275
+ }
276
+ return null;
277
+ }
278
+ function commandWrapper(words) {
279
+ const command = path.basename(words[0] ?? "").toLowerCase();
280
+ if (["bash", "sh", "zsh"].includes(command))
281
+ return shellCommandWrapper(words);
282
+ if (command === "sudo") {
283
+ let index = 1;
284
+ while (index < words.length) {
285
+ const option = words[index] ?? "";
286
+ if (option === "--") {
287
+ index += 1;
288
+ break;
289
+ }
290
+ if (option === "-u" || option === "--user") {
291
+ if (!words[index + 1])
292
+ return { kind: "invalid" };
293
+ index += 2;
294
+ continue;
295
+ }
296
+ if (option.startsWith("--user=") || /^-u.+/.test(option)) {
297
+ index += 1;
298
+ continue;
299
+ }
300
+ if (["-A", "-b", "-E", "-H", "-K", "-k", "-n", "-P", "-S", "-V", "--askpass", "--background", "--preserve-env", "--set-home", "--remove-timestamp", "--reset-timestamp", "--non-interactive", "--preserve-groups", "--stdin", "--version"].includes(option)) {
301
+ index += 1;
302
+ continue;
303
+ }
304
+ if (option.startsWith("-"))
305
+ return { kind: "invalid" };
306
+ break;
307
+ }
308
+ return index < words.length ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
309
+ }
310
+ if (command === "command") {
311
+ let index = 1;
312
+ while (words[index] === "-p")
313
+ index += 1;
314
+ if (words[index] === "--")
315
+ index += 1;
316
+ if (words[index]?.startsWith("-"))
317
+ return { kind: "invalid" };
318
+ return index < words.length ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
218
319
  }
219
- return words.slice(index);
320
+ if (command === "env") {
321
+ const index = skipEnvOptions(words, 0);
322
+ if (index < 0)
323
+ return { kind: "invalid" };
324
+ return index < words.length ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
325
+ }
326
+ if (command === "timeout") {
327
+ const index = skipTimeoutOptions(words, 0);
328
+ return index >= 0 ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
329
+ }
330
+ if (command === "nice") {
331
+ let index = 1;
332
+ while (index < words.length) {
333
+ const option = words[index] ?? "";
334
+ if (option === "--") {
335
+ index += 1;
336
+ break;
337
+ }
338
+ if (option === "-n" || option === "--adjustment") {
339
+ if (!/^-?\d+$/.test(words[index + 1] ?? ""))
340
+ return { kind: "invalid" };
341
+ index += 2;
342
+ continue;
343
+ }
344
+ if (/^--adjustment=-?\d+$/.test(option) || /^-\d+$/.test(option)) {
345
+ index += 1;
346
+ continue;
347
+ }
348
+ if (option.startsWith("-"))
349
+ return { kind: "invalid" };
350
+ break;
351
+ }
352
+ return index < words.length ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
353
+ }
354
+ if (command === "nohup") {
355
+ const index = words[1] === "--" ? 2 : 1;
356
+ return index < words.length ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
357
+ }
358
+ return null;
359
+ }
360
+ function containsMergeCandidate(command) {
361
+ // This lossy scan only decides whether malformed input resembles a merge;
362
+ // the parser never uses it to recover a command or target.
363
+ const words = command.replace(/["'`\\]/g, " ").match(/\S+/g) ?? [];
364
+ const valueOptions = new Set([
365
+ "-R", "--repo", "--hostname", "--config", "--cache", "--jq", "--template",
366
+ "--preview", "--http-unix-socket", "--git-protocol",
367
+ ]);
368
+ for (let index = 0; index < words.length; index += 1) {
369
+ if (words[index]?.toLowerCase() !== "gh")
370
+ continue;
371
+ let subcommandIndex = index + 1;
372
+ while (subcommandIndex < words.length) {
373
+ const word = words[subcommandIndex] ?? "";
374
+ if (word === "--")
375
+ break;
376
+ if (valueOptions.has(word)) {
377
+ if (subcommandIndex + 1 >= words.length)
378
+ break;
379
+ subcommandIndex += 2;
380
+ continue;
381
+ }
382
+ if (word.startsWith("--repo=") || word.startsWith("--hostname=") || /^-R.+/.test(word)) {
383
+ subcommandIndex += 1;
384
+ continue;
385
+ }
386
+ if (word.startsWith("-")) {
387
+ subcommandIndex += 1;
388
+ continue;
389
+ }
390
+ break;
391
+ }
392
+ const subcommand = words[subcommandIndex];
393
+ if (subcommand === "pr") {
394
+ let actionIndex = subcommandIndex + 1;
395
+ while (actionIndex < words.length) {
396
+ const word = words[actionIndex] ?? "";
397
+ if (word === "--")
398
+ break;
399
+ if (valueOptions.has(word)) {
400
+ if (actionIndex + 1 >= words.length)
401
+ break;
402
+ actionIndex += 2;
403
+ continue;
404
+ }
405
+ if (word.startsWith("--repo=") || word.startsWith("--hostname=") || /^-R.+/.test(word)) {
406
+ actionIndex += 1;
407
+ continue;
408
+ }
409
+ if (word.startsWith("-")) {
410
+ actionIndex += 1;
411
+ continue;
412
+ }
413
+ break;
414
+ }
415
+ if (words[actionIndex] === "merge")
416
+ return true;
417
+ }
418
+ if (subcommand === "api" && words.slice(subcommandIndex + 1).some((word) => /\/merge\b/i.test(word) || /\bmergePullRequest\b/i.test(word))) {
419
+ return true;
420
+ }
421
+ }
422
+ return false;
220
423
  }
221
424
  function parsePullUrl(raw) {
222
425
  if (!/^https?:\/\//i.test(raw))
@@ -330,8 +533,7 @@ function apiMergeTarget(words, apiIndex) {
330
533
  }
331
534
  return null;
332
535
  }
333
- function parseSegment(segment) {
334
- const words = unwrapCommand(shellWords(segment));
536
+ function parseSegmentWords(words) {
335
537
  if (words[0] === "git" && words.slice(1).includes("merge")) {
336
538
  return {
337
539
  kind: "git",
@@ -351,13 +553,77 @@ function parseSegment(segment) {
351
553
  return null;
352
554
  return apiMergeTarget(words, apiIndex);
353
555
  }
556
+ function unparseableMergeTarget() {
557
+ return {
558
+ kind: "pr",
559
+ repo: null,
560
+ pr: null,
561
+ pinnedHead: null,
562
+ unresolvedReason: "merge command could not be parsed safely",
563
+ };
564
+ }
565
+ function recordUnparseableMerge(state) {
566
+ state.unparseableMerge = true;
567
+ state.targets.push(unparseableMergeTarget());
568
+ }
569
+ function parseCommandWords(words, source, depth, state) {
570
+ let commandWords = words;
571
+ while (SHELL_ASSIGNMENT.test(commandWords[0] ?? ""))
572
+ commandWords = commandWords.slice(1);
573
+ if (commandWords.length === 0)
574
+ return;
575
+ const wrapper = commandWrapper(commandWords);
576
+ if (wrapper) {
577
+ if (wrapper.kind === "invalid" || depth >= MAX_COMMAND_WRAPPER_DEPTH) {
578
+ if (containsMergeCandidate(source))
579
+ recordUnparseableMerge(state);
580
+ return;
581
+ }
582
+ if (wrapper.kind === "script") {
583
+ parseCommandList(wrapper.script, depth + 1, state);
584
+ return;
585
+ }
586
+ parseCommandWords(wrapper.words, source, depth + 1, state);
587
+ return;
588
+ }
589
+ const target = parseSegmentWords(commandWords);
590
+ if (target)
591
+ state.targets.push(target);
592
+ }
593
+ function parseCommandList(command, depth, state) {
594
+ let segments;
595
+ try {
596
+ segments = splitCommandChain(command);
597
+ }
598
+ catch (error) {
599
+ if (error instanceof LanesError && error.error === "repo_hold_command_invalid" && containsMergeCandidate(command)) {
600
+ recordUnparseableMerge(state);
601
+ return;
602
+ }
603
+ throw error;
604
+ }
605
+ for (const segment of segments) {
606
+ let words;
607
+ try {
608
+ words = shellWords(segment);
609
+ }
610
+ catch (error) {
611
+ if (error instanceof LanesError && error.error === "repo_hold_command_invalid" && containsMergeCandidate(segment)) {
612
+ recordUnparseableMerge(state);
613
+ continue;
614
+ }
615
+ throw error;
616
+ }
617
+ parseCommandWords(words, segment, depth, state);
618
+ }
619
+ }
354
620
  export function parseMergeCommands(command) {
355
- const targets = splitCommandChain(command)
356
- .map(parseSegment)
357
- .filter((target) => target !== null);
621
+ const state = { targets: [], unparseableMerge: false };
622
+ parseCommandList(command, 0, state);
358
623
  return {
359
- targets,
360
- unresolved: targets.some((target) => target.unresolvedReason !== null),
624
+ targets: state.targets,
625
+ unresolved: state.targets.some((target) => target.unresolvedReason !== null),
626
+ unparseableMerge: state.unparseableMerge,
361
627
  };
362
628
  }
363
629
  //# sourceMappingURL=merge-command.js.map
@@ -1,5 +1,6 @@
1
1
  import { type LaneRecord, type SeniorRef } from "./types.js";
2
2
  export declare const DEFAULT_ORPHAN_MIN_AGE_MINUTES = 30;
3
+ export declare const DEFAULT_ORPHAN_USER_IDLE_HOURS = 72;
3
4
  export interface OrphanLaneFinding {
4
5
  lane_id: string;
5
6
  senior: SeniorRef;
@@ -7,12 +8,18 @@ export interface OrphanLaneFinding {
7
8
  age_minutes: number;
8
9
  account: string | null;
9
10
  host: LaneRecord["host"];
11
+ reason: "senior_ended" | "senior_missing" | "user_idle";
10
12
  }
11
13
  export declare function parseOrphanMinAge(value: string | undefined): number;
14
+ export declare function parseOrphanUserIdleHours(value: string | undefined): number;
12
15
  export declare function findOrphanLanes(input: {
13
16
  lanes: readonly LaneRecord[];
17
+ candidateLaneIds: ReadonlySet<string>;
14
18
  now: Date;
15
19
  minAgeMinutes: number;
20
+ userIdleHours: number;
16
21
  sessionState: (sessionId: string, now: Date) => string | null;
22
+ lastActivityAt: (lane: LaneRecord) => number;
23
+ hasOpenStory: (lane: LaneRecord) => boolean;
17
24
  }): OrphanLaneFinding[];
18
25
  //# sourceMappingURL=orphans.d.ts.map