@united-workforce/cli 0.5.0 → 0.6.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 (55) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/__tests__/config-text-renderer.test.d.ts +2 -0
  3. package/dist/__tests__/config-text-renderer.test.d.ts.map +1 -0
  4. package/dist/__tests__/config-text-renderer.test.js +137 -0
  5. package/dist/__tests__/config-text-renderer.test.js.map +1 -0
  6. package/dist/__tests__/issue-180-workflow-ref-removed.test.js +1 -1
  7. package/dist/__tests__/thread-agent-failure-suspended.test.d.ts +2 -0
  8. package/dist/__tests__/thread-agent-failure-suspended.test.d.ts.map +1 -0
  9. package/dist/__tests__/thread-agent-failure-suspended.test.js +332 -0
  10. package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -0
  11. package/dist/__tests__/thread-join.test.d.ts +2 -0
  12. package/dist/__tests__/thread-join.test.d.ts.map +1 -0
  13. package/dist/__tests__/thread-join.test.js +77 -0
  14. package/dist/__tests__/thread-join.test.js.map +1 -0
  15. package/dist/__tests__/thread-poke.test.js +4 -1
  16. package/dist/__tests__/thread-poke.test.js.map +1 -1
  17. package/dist/__tests__/workflow-paths.test.d.ts +2 -0
  18. package/dist/__tests__/workflow-paths.test.d.ts.map +1 -0
  19. package/dist/__tests__/workflow-paths.test.js +261 -0
  20. package/dist/__tests__/workflow-paths.test.js.map +1 -0
  21. package/dist/cli.js +18 -1
  22. package/dist/cli.js.map +1 -1
  23. package/dist/commands/config.d.ts +5 -0
  24. package/dist/commands/config.d.ts.map +1 -1
  25. package/dist/commands/config.js +69 -3
  26. package/dist/commands/config.js.map +1 -1
  27. package/dist/commands/thread.d.ts +12 -0
  28. package/dist/commands/thread.d.ts.map +1 -1
  29. package/dist/commands/thread.js +183 -8
  30. package/dist/commands/thread.js.map +1 -1
  31. package/dist/commands/workflow.d.ts +1 -1
  32. package/dist/commands/workflow.d.ts.map +1 -1
  33. package/dist/commands/workflow.js +24 -4
  34. package/dist/commands/workflow.js.map +1 -1
  35. package/dist/output-mappers.d.ts.map +1 -1
  36. package/dist/output-mappers.js +1 -1
  37. package/dist/output-mappers.js.map +1 -1
  38. package/dist/store.d.ts +11 -0
  39. package/dist/store.d.ts.map +1 -1
  40. package/dist/store.js +20 -1
  41. package/dist/store.js.map +1 -1
  42. package/package.json +11 -11
  43. package/src/__tests__/config-text-renderer.test.ts +156 -0
  44. package/src/__tests__/issue-180-workflow-ref-removed.test.ts +1 -1
  45. package/src/__tests__/thread-agent-failure-suspended.test.ts +406 -0
  46. package/src/__tests__/thread-join.test.ts +103 -0
  47. package/src/__tests__/thread-poke.test.ts +4 -1
  48. package/src/__tests__/workflow-paths.test.ts +337 -0
  49. package/src/cli.ts +19 -0
  50. package/src/commands/config.ts +74 -3
  51. package/src/commands/thread.ts +233 -8
  52. package/src/commands/workflow.ts +29 -4
  53. package/src/output-mappers.ts +2 -1
  54. package/src/store.ts +25 -1
  55. package/LICENSE +0 -21
@@ -61,7 +61,13 @@ import {
61
61
  } from "../store.js";
62
62
  import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
63
63
  import { validateWorkflow } from "../validate-semantic.js";
64
- import { getConfigPath, getNestedValue, loadConfig, parseDotPath } from "./config.js";
64
+ import {
65
+ getConfigPath,
66
+ getNestedValue,
67
+ loadConfig,
68
+ loadWorkflowPaths,
69
+ parseDotPath,
70
+ } from "./config.js";
65
71
  import {
66
72
  type ChainState,
67
73
  collectOrderedSteps,
@@ -168,6 +174,13 @@ async function resolveActiveThreadStatus(
168
174
  return "running";
169
175
  }
170
176
 
177
+ // Check the persisted entry status first — agent failure suspends the thread
178
+ // via markThreadSuspended() without producing a $SUSPEND output in CAS.
179
+ const entry = getThread(uwf.varStore, threadId);
180
+ if (entry !== null && entry.status === "suspended") {
181
+ return "suspended";
182
+ }
183
+
171
184
  const chain = walkChain(uwf, head);
172
185
  const { lastOutput } = resolveEvaluateArgs(uwf, chain);
173
186
  if (readSuspendReason(lastOutput) !== null) {
@@ -227,9 +240,20 @@ function buildResumePrompt(graphPrompt: string, supplement: string | null): stri
227
240
  return `${graphPrompt}\n\n${supplement}`;
228
241
  }
229
242
 
243
+ /**
244
+ * Error thrown by failStep so that callers can catch it, persist
245
+ * thread state (e.g. suspend), and then re-throw / exit.
246
+ */
247
+ class StepFailureError extends Error {
248
+ constructor(message: string) {
249
+ super(message);
250
+ this.name = "StepFailureError";
251
+ }
252
+ }
253
+
230
254
  function failStep(plog: ProcessLogger, message: string): never {
231
255
  plog.log(PL_STEP_ERROR, message, null);
232
- fail(message);
256
+ throw new StepFailureError(message);
233
257
  }
234
258
 
235
259
  /**
@@ -346,6 +370,49 @@ async function findWorkflowInParents(startDir: string, name: string): Promise<st
346
370
  return null;
347
371
  }
348
372
 
373
+ /**
374
+ * Search for a workflow by name directly in a directory (not inside .workflows/).
375
+ * Used for workflowPaths resolution — each path dir contains YAMLs at top level.
376
+ * Checks flat files (<name>.yaml/.yml) and folder layout (<name>/index.yaml/.yml).
377
+ */
378
+ async function findWorkflowInPath(dir: string, name: string): Promise<string | null> {
379
+ // Check flat YAML files
380
+ for (const ext of [".yaml", ".yml"]) {
381
+ const result = await workflowFileExists(dir, name, ext);
382
+ if (result !== null) {
383
+ return result;
384
+ }
385
+ }
386
+ // Check folder-based layout (<name>/index.yaml)
387
+ for (const indexName of ["index.yaml", "index.yml"]) {
388
+ const candidate = resolvePath(dir, name, indexName);
389
+ try {
390
+ await access(candidate);
391
+ return candidate;
392
+ } catch {
393
+ /* not found */
394
+ }
395
+ }
396
+ return null;
397
+ }
398
+
399
+ /**
400
+ * Search workflowPaths directories for a workflow by name.
401
+ * Searches each directory in order; first match wins.
402
+ */
403
+ async function findWorkflowInPaths(
404
+ dirs: ReadonlyArray<string>,
405
+ name: string,
406
+ ): Promise<string | null> {
407
+ for (const dir of dirs) {
408
+ const found = await findWorkflowInPath(dir, name);
409
+ if (found !== null) {
410
+ return found;
411
+ }
412
+ }
413
+ return null;
414
+ }
415
+
349
416
  async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promise<CasRef> {
350
417
  let text: string;
351
418
  try {
@@ -421,6 +488,13 @@ async function resolveWorkflowCasRef(
421
488
  return materializeLocalWorkflow(uwf, localPath);
422
489
  }
423
490
 
491
+ // Strategy 3.5: workflowPaths global directories
492
+ const workflowPaths = loadWorkflowPaths(uwf.storageRoot);
493
+ const pathsFile = await findWorkflowInPaths(workflowPaths, trimmed);
494
+ if (pathsFile !== null) {
495
+ return materializeLocalWorkflow(uwf, pathsFile);
496
+ }
497
+
424
498
  // Strategy 4: Global registry fallback
425
499
  const registry = loadWorkflowRegistry(uwf.varStore);
426
500
  const hash = resolveWorkflowHash(registry, trimmed);
@@ -1378,7 +1452,18 @@ export async function cmdThreadPoke(
1378
1452
  // Spawn the agent. The agent will create a new StepNode with prev=oldHead (it reads
1379
1453
  // the active thread head). After the agent returns, we rewrite that node's prev so
1380
1454
  // that the new head replaces the old head instead of appending after it.
1381
- const agentResult = spawnAgent(plog, agent, threadId, role, prompt, effectiveCwd);
1455
+ let agentResult: AdapterOutput;
1456
+ try {
1457
+ agentResult = spawnAgent(plog, agent, threadId, role, prompt, effectiveCwd);
1458
+ } catch (e) {
1459
+ if (e instanceof StepFailureError) {
1460
+ // Fatal agent failure in poke — persist suspended state before propagating
1461
+ const uwfErr = await createUwfStore(storageRoot);
1462
+ const errEntry = getThread(uwfErr.varStore, threadId) ?? entry;
1463
+ setThread(uwfErr.varStore, threadId, markThreadSuspended(errEntry, role, e.message));
1464
+ }
1465
+ throw e;
1466
+ }
1382
1467
  const agentStepHash = agentResult.stepHash as CasRef;
1383
1468
 
1384
1469
  plog.log(PL_AGENT_DONE, `agent returned head=${agentStepHash}`, null);
@@ -1507,6 +1592,95 @@ export async function cmdThreadExec(
1507
1592
  }
1508
1593
  }
1509
1594
 
1595
+ const JOIN_POLL_INTERVAL_MS = 1000;
1596
+
1597
+ /**
1598
+ * Block until a running thread finishes (marker disappears), then return the
1599
+ * final thread state in the same `StepOutput[]` format that `cmdThreadExec`
1600
+ * produces.
1601
+ *
1602
+ * - If the thread is currently running → poll until it stops, then return
1603
+ * its final state.
1604
+ * - If the thread is not running → return its current state immediately.
1605
+ *
1606
+ * An optional `timeoutMs` aborts the wait with an error when exceeded.
1607
+ */
1608
+ export async function cmdThreadJoin(
1609
+ storageRoot: string,
1610
+ threadId: ThreadId,
1611
+ timeoutMs: number | null,
1612
+ ): Promise<StepOutput[]> {
1613
+ const uwf = await createUwfStore(storageRoot);
1614
+ const entry = getThread(uwf.varStore, threadId);
1615
+ if (entry === null) {
1616
+ fail(`thread not found: ${threadId}`);
1617
+ }
1618
+
1619
+ // Wait for running marker to disappear
1620
+ const deadline = timeoutMs !== null ? Date.now() + timeoutMs : null;
1621
+
1622
+ while ((await isThreadRunning(storageRoot, threadId)) !== null) {
1623
+ if (deadline !== null && Date.now() >= deadline) {
1624
+ fail(`join timed out after ${timeoutMs}ms — thread ${threadId} is still running`);
1625
+ }
1626
+ await new Promise<void>((resolve) => {
1627
+ setTimeout(resolve, JOIN_POLL_INTERVAL_MS);
1628
+ });
1629
+ }
1630
+
1631
+ // Thread is no longer running — read final state.
1632
+ // Re-open the store to get the latest state written by the worker.
1633
+ const freshUwf = await createUwfStore(storageRoot);
1634
+ const freshEntry = getThread(freshUwf.varStore, threadId);
1635
+ if (freshEntry === null) {
1636
+ fail(`thread disappeared after join: ${threadId}`);
1637
+ }
1638
+
1639
+ const activeHead = freshEntry.head;
1640
+ const workflowHash = resolveWorkflowFromHead(freshUwf, activeHead);
1641
+ if (workflowHash === null) {
1642
+ fail(`failed to resolve workflow from head: ${activeHead}`);
1643
+ }
1644
+
1645
+ // Build the StepOutput matching exec's format
1646
+ if (freshEntry.status === "end" || freshEntry.status === "cancelled") {
1647
+ return [
1648
+ {
1649
+ workflow: workflowHash,
1650
+ thread: threadId,
1651
+ head: activeHead,
1652
+ status: freshEntry.status,
1653
+ currentRole: null,
1654
+ suspendedRole: null,
1655
+ suspendMessage: null,
1656
+ done: true,
1657
+ background: null,
1658
+ error: null,
1659
+ },
1660
+ ];
1661
+ }
1662
+
1663
+ // Active thread — resolve detailed status
1664
+ const status = await resolveActiveThreadStatus(storageRoot, threadId, freshUwf, activeHead);
1665
+ const currentRole = resolveCurrentRole(freshUwf, activeHead, workflowHash);
1666
+ const suspendFields = resolveSuspendFieldsForShow(freshEntry, status, freshUwf, activeHead);
1667
+
1668
+ return [
1669
+ {
1670
+ workflow: workflowHash,
1671
+ thread: threadId,
1672
+ head: activeHead,
1673
+ status,
1674
+ currentRole,
1675
+ suspendedRole: suspendFields.suspendedRole,
1676
+ suspendMessage: suspendFields.suspendMessage,
1677
+ done: false,
1678
+ background: null,
1679
+ error: null,
1680
+ },
1681
+ ];
1682
+ }
1683
+
1510
1684
  async function resolveActiveThreadWorkflowHash(
1511
1685
  storageRoot: string,
1512
1686
  threadId: ThreadId,
@@ -1761,6 +1935,51 @@ async function cmdThreadStepOnce(
1761
1935
  });
1762
1936
 
1763
1937
  loadDotenv({ path: getEnvPath(storageRoot) });
1938
+
1939
+ // Wrap agent execution in a try-catch: when the agent command crashes
1940
+ // (non-zero exit, unparseable output, invalid CAS node, etc.), failStep throws
1941
+ // StepFailureError. We catch it to persist suspended state before re-throwing
1942
+ // so the CLI still exits non-zero.
1943
+ try {
1944
+ return await executeAndProcessAgentStep(
1945
+ storageRoot,
1946
+ threadId,
1947
+ headHash,
1948
+ workflowHash,
1949
+ workflow,
1950
+ role,
1951
+ edgePrompt,
1952
+ effectiveCwd,
1953
+ agent,
1954
+ plog,
1955
+ );
1956
+ } catch (e) {
1957
+ if (e instanceof StepFailureError) {
1958
+ // Fatal agent failure — persist suspended state before propagating
1959
+ const uwfErr = await createUwfStore(storageRoot);
1960
+ const errEntry = getThread(uwfErr.varStore, threadId) ?? createThreadIndexEntry(headHash);
1961
+ setThread(uwfErr.varStore, threadId, markThreadSuspended(errEntry, role, e.message));
1962
+ }
1963
+ throw e;
1964
+ }
1965
+ }
1966
+
1967
+ /**
1968
+ * Execute the agent command and process the result. Separated from cmdThreadStepOnce
1969
+ * so that fatal failures (StepFailureError) can be caught and handled by the caller.
1970
+ */
1971
+ async function executeAndProcessAgentStep(
1972
+ storageRoot: string,
1973
+ threadId: ThreadId,
1974
+ headHash: CasRef,
1975
+ workflowHash: CasRef,
1976
+ workflow: WorkflowPayload,
1977
+ role: string,
1978
+ edgePrompt: string,
1979
+ effectiveCwd: string,
1980
+ agent: AgentConfig,
1981
+ plog: ProcessLogger,
1982
+ ): Promise<StepOutput> {
1764
1983
  const agentResult = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd);
1765
1984
  const newHead = agentResult.stepHash as CasRef;
1766
1985
 
@@ -1778,22 +1997,28 @@ async function cmdThreadStepOnce(
1778
1997
  // next exec (until eventual success records `previousAttempts` linking the
1779
1998
  // failed step hashes).
1780
1999
  if (agentResult.isError === true) {
2000
+ const errorMsg = agentResult.errorMessage ?? "agent reported error";
1781
2001
  plog.log(
1782
2002
  PL_AGENT_ERROR,
1783
- `agent reported recoverable failure stepHash=${newHead} message=${agentResult.errorMessage ?? ""}`,
2003
+ `agent reported recoverable failure stepHash=${newHead} message=${errorMsg}`,
1784
2004
  null,
1785
2005
  );
2006
+
2007
+ // Persist suspended state so `thread list --status suspended` reflects the failure.
2008
+ const priorEntry = getThread(uwfAfter.varStore, threadId) ?? createThreadIndexEntry(headHash);
2009
+ setThread(uwfAfter.varStore, threadId, markThreadSuspended(priorEntry, role, errorMsg));
2010
+
1786
2011
  return {
1787
2012
  workflow: workflowHash,
1788
2013
  thread: threadId,
1789
2014
  head: headHash,
1790
- status: "idle",
2015
+ status: "suspended",
1791
2016
  currentRole: role,
1792
- suspendedRole: null,
1793
- suspendMessage: null,
2017
+ suspendedRole: role,
2018
+ suspendMessage: errorMsg,
1794
2019
  done: false,
1795
2020
  background: null,
1796
- error: { stepHash: newHead, message: agentResult.errorMessage ?? "agent reported error" },
2021
+ error: { stepHash: newHead, message: errorMsg },
1797
2022
  };
1798
2023
  }
1799
2024
 
@@ -10,6 +10,7 @@ import { createIncludeTag } from "../include.js";
10
10
  import {
11
11
  createUwfStore,
12
12
  discoverProjectWorkflows,
13
+ discoverWorkflowPathsEntries,
13
14
  findRegistryName,
14
15
  loadWorkflowRegistry,
15
16
  resolveProjectWorkflowFile,
@@ -24,8 +25,9 @@ import {
24
25
  parseWorkflowPayload,
25
26
  } from "../validate.js";
26
27
  import { validateWorkflow } from "../validate-semantic.js";
28
+ import { loadWorkflowPaths } from "./config.js";
27
29
 
28
- export type WorkflowOrigin = "local" | "global";
30
+ export type WorkflowOrigin = "local" | "paths" | "global";
29
31
 
30
32
  export type WorkflowListEntry = {
31
33
  name: string;
@@ -160,6 +162,9 @@ export async function cmdWorkflowAdd(
160
162
  storageRoot: string,
161
163
  filePath: string,
162
164
  ): Promise<WorkflowAddOutput> {
165
+ process.stderr.write(
166
+ "warning: `uwf workflow add` is deprecated. Use workflowPaths in ~/.uwf/config.yaml instead. See issue #360.\n",
167
+ );
163
168
  let text: string;
164
169
  try {
165
170
  text = await readFile(filePath, "utf8");
@@ -295,6 +300,14 @@ async function resolveWorkflowCasRefForShow(
295
300
  return materializeLocalWorkflowForShow(uwf, localPath);
296
301
  }
297
302
 
303
+ // Strategy 3.5: workflowPaths global directories
304
+ const workflowPaths = loadWorkflowPaths(uwf.storageRoot);
305
+ const pathsEntries = await discoverWorkflowPathsEntries(workflowPaths);
306
+ const pathsFile = resolveProjectWorkflowFile(pathsEntries, trimmed);
307
+ if (pathsFile !== null) {
308
+ return materializeLocalWorkflowForShow(uwf, pathsFile);
309
+ }
310
+
298
311
  // Strategy 4: Global registry fallback
299
312
  const registry = loadWorkflowRegistry(uwf.varStore);
300
313
  const hash = resolveWorkflowHash(registry, trimmed);
@@ -344,18 +357,30 @@ export async function cmdWorkflowList(
344
357
  ): Promise<WorkflowListEntry[]> {
345
358
  const uwf = await createUwfStore(storageRoot);
346
359
  const localEntries = await discoverProjectWorkflows(projectRoot);
360
+ const workflowPaths = loadWorkflowPaths(storageRoot);
361
+ const pathsEntries = await discoverWorkflowPathsEntries(workflowPaths);
347
362
  const registry = loadWorkflowRegistry(uwf.varStore);
348
363
 
349
364
  const result: WorkflowListEntry[] = [];
350
- const localNames = new Set<string>();
365
+ const seenNames = new Set<string>();
351
366
 
367
+ // Layer 1: local .workflows/ (highest priority)
352
368
  for (const entry of localEntries) {
353
- localNames.add(entry.name);
369
+ seenNames.add(entry.name);
354
370
  result.push({ name: entry.name, hash: "(local)", origin: "local" });
355
371
  }
356
372
 
373
+ // Layer 2: workflowPaths directories
374
+ for (const entry of pathsEntries) {
375
+ if (!seenNames.has(entry.name)) {
376
+ seenNames.add(entry.name);
377
+ result.push({ name: entry.name, hash: "(paths)", origin: "paths" });
378
+ }
379
+ }
380
+
381
+ // Layer 3: global registry (lowest priority)
357
382
  for (const [name, hash] of Object.entries(registry)) {
358
- if (!localNames.has(name)) {
383
+ if (!seenNames.has(name)) {
359
384
  result.push({ name, hash, origin: "global" });
360
385
  }
361
386
  }
@@ -229,7 +229,8 @@ export function toWorkflowListPayload(entries: WorkflowListEntry[]): WorkflowLis
229
229
  items: entries.map((e) => ({
230
230
  name: e.name,
231
231
  hash: e.hash,
232
- source: e.origin === "local" ? ".workflows" : "registry",
232
+ source:
233
+ e.origin === "local" ? ".workflows" : e.origin === "paths" ? "workflowPaths" : "registry",
233
234
  description: "",
234
235
  })),
235
236
  };
package/src/store.ts CHANGED
@@ -56,7 +56,7 @@ async function findIndexWorkflow(
56
56
  * Scan a single directory for workflow entries (flat YAML files + folder/index.yaml).
57
57
  * Returns discovered entries. Returns empty array if directory does not exist.
58
58
  */
59
- async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
59
+ export async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
60
60
  let dirents: Dirent[];
61
61
  try {
62
62
  dirents = await readdir(dir, { withFileTypes: true });
@@ -159,6 +159,30 @@ export function getDefaultStorageRoot(): string {
159
159
  return join(homedir(), ".uwf");
160
160
  }
161
161
 
162
+ /**
163
+ * Discover workflows from workflowPaths directories.
164
+ * Each directory is scanned directly for YAML files (like scanWorkflowDir).
165
+ * Earlier dirs in the list take priority on name collisions.
166
+ */
167
+ export async function discoverWorkflowPathsEntries(
168
+ dirs: ReadonlyArray<string>,
169
+ ): Promise<ProjectWorkflowEntry[]> {
170
+ const seen = new Set<string>();
171
+ const result: ProjectWorkflowEntry[] = [];
172
+
173
+ for (const dir of dirs) {
174
+ const entries = await scanWorkflowDir(dir);
175
+ for (const entry of entries) {
176
+ if (!seen.has(entry.name)) {
177
+ seen.add(entry.name);
178
+ result.push(entry);
179
+ }
180
+ }
181
+ }
182
+
183
+ return result;
184
+ }
185
+
162
186
  /**
163
187
  * Resolve storage root.
164
188
  * Priority: `UWF_HOME` → default.
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 United Workforce
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.