@skillcdn/console 0.1.13 → 0.1.15

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 (170) hide show
  1. package/README.md +5 -4
  2. package/SKILLCDN.md +2 -0
  3. package/dist/agent-name.d.ts +3 -0
  4. package/dist/agent-name.d.ts.map +1 -0
  5. package/dist/agent-name.js +9 -0
  6. package/dist/agent-name.js.map +1 -0
  7. package/dist/api.d.ts +6 -4
  8. package/dist/api.d.ts.map +1 -1
  9. package/dist/api.js +6 -4
  10. package/dist/api.js.map +1 -1
  11. package/dist/cli/cli.d.ts +2 -0
  12. package/dist/cli/cli.d.ts.map +1 -1
  13. package/dist/cli/cli.js +215 -22
  14. package/dist/cli/cli.js.map +1 -1
  15. package/dist/cli/format.d.ts +14 -4
  16. package/dist/cli/format.d.ts.map +1 -1
  17. package/dist/cli/format.js +59 -10
  18. package/dist/cli/format.js.map +1 -1
  19. package/dist/cli/help.d.ts.map +1 -1
  20. package/dist/cli/help.js +27 -11
  21. package/dist/cli/help.js.map +1 -1
  22. package/dist/cli/main.js +5 -0
  23. package/dist/cli/main.js.map +1 -1
  24. package/dist/client.d.ts +20 -2
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +11 -2
  27. package/dist/client.js.map +1 -1
  28. package/dist/components/calendar.d.ts +15 -0
  29. package/dist/components/calendar.d.ts.map +1 -1
  30. package/dist/components/calendar.js +78 -41
  31. package/dist/components/calendar.js.map +1 -1
  32. package/dist/components/connect.d.ts +4 -1
  33. package/dist/components/connect.d.ts.map +1 -1
  34. package/dist/components/connect.js +7 -3
  35. package/dist/components/connect.js.map +1 -1
  36. package/dist/components/decision-list.d.ts.map +1 -1
  37. package/dist/components/decision-list.js +3 -3
  38. package/dist/components/decision-list.js.map +1 -1
  39. package/dist/components/documents.d.ts +5 -0
  40. package/dist/components/documents.d.ts.map +1 -1
  41. package/dist/components/documents.js +3 -2
  42. package/dist/components/documents.js.map +1 -1
  43. package/dist/components/event-feed.d.ts +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +10 -1
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/people.d.ts +24 -1
  48. package/dist/components/people.d.ts.map +1 -1
  49. package/dist/components/people.js +35 -3
  50. package/dist/components/people.js.map +1 -1
  51. package/dist/components/projects.d.ts +6 -0
  52. package/dist/components/projects.d.ts.map +1 -1
  53. package/dist/components/projects.js +3 -3
  54. package/dist/components/projects.js.map +1 -1
  55. package/dist/components/rules.d.ts +40 -0
  56. package/dist/components/rules.d.ts.map +1 -0
  57. package/dist/components/rules.js +46 -0
  58. package/dist/components/rules.js.map +1 -0
  59. package/dist/components/runs.d.ts +1 -1
  60. package/dist/components/runs.d.ts.map +1 -1
  61. package/dist/components/runs.js +2 -1
  62. package/dist/components/runs.js.map +1 -1
  63. package/dist/components/skills.d.ts.map +1 -1
  64. package/dist/components/skills.js +5 -1
  65. package/dist/components/skills.js.map +1 -1
  66. package/dist/components/tokens.d.ts.map +1 -1
  67. package/dist/components/tokens.js +2 -9
  68. package/dist/components/tokens.js.map +1 -1
  69. package/dist/components/ui.d.ts +7 -2
  70. package/dist/components/ui.d.ts.map +1 -1
  71. package/dist/components/ui.js +13 -0
  72. package/dist/components/ui.js.map +1 -1
  73. package/dist/components/work.d.ts +9 -1
  74. package/dist/components/work.d.ts.map +1 -1
  75. package/dist/components/work.js +8 -3
  76. package/dist/components/work.js.map +1 -1
  77. package/dist/connect-page.d.ts.map +1 -1
  78. package/dist/connect-page.js +1 -1
  79. package/dist/connect-page.js.map +1 -1
  80. package/dist/console.css +253 -13
  81. package/dist/console.d.ts.map +1 -1
  82. package/dist/console.js +203 -49
  83. package/dist/console.js.map +1 -1
  84. package/dist/data.d.ts +19 -2
  85. package/dist/data.d.ts.map +1 -1
  86. package/dist/data.js +27 -1
  87. package/dist/data.js.map +1 -1
  88. package/dist/dates.d.ts +4 -0
  89. package/dist/dates.d.ts.map +1 -1
  90. package/dist/dates.js +26 -0
  91. package/dist/dates.js.map +1 -1
  92. package/dist/i18n/en.d.ts +90 -28
  93. package/dist/i18n/en.d.ts.map +1 -1
  94. package/dist/i18n/en.js +89 -31
  95. package/dist/i18n/en.js.map +1 -1
  96. package/dist/i18n/ko.d.ts.map +1 -1
  97. package/dist/i18n/ko.js +89 -31
  98. package/dist/i18n/ko.js.map +1 -1
  99. package/dist/index.d.ts +6 -5
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +6 -5
  102. package/dist/index.js.map +1 -1
  103. package/dist/limits.d.ts +4 -0
  104. package/dist/limits.d.ts.map +1 -1
  105. package/dist/limits.js +4 -0
  106. package/dist/limits.js.map +1 -1
  107. package/dist/router.d.ts +34 -2
  108. package/dist/router.d.ts.map +1 -1
  109. package/dist/router.js +55 -7
  110. package/dist/router.js.map +1 -1
  111. package/dist/routes.d.ts +14 -4
  112. package/dist/routes.d.ts.map +1 -1
  113. package/dist/routes.js +17 -2
  114. package/dist/routes.js.map +1 -1
  115. package/dist/rules.d.ts +28 -0
  116. package/dist/rules.d.ts.map +1 -0
  117. package/dist/rules.js +102 -0
  118. package/dist/rules.js.map +1 -0
  119. package/dist/schemas.d.ts +652 -4
  120. package/dist/schemas.d.ts.map +1 -1
  121. package/dist/schemas.js +96 -6
  122. package/dist/schemas.js.map +1 -1
  123. package/dist/vocabulary.d.ts +21 -1
  124. package/dist/vocabulary.d.ts.map +1 -1
  125. package/dist/vocabulary.js +21 -0
  126. package/dist/vocabulary.js.map +1 -1
  127. package/docs/specs/cli.md +13 -9
  128. package/docs/specs/rest.md +32 -8
  129. package/package.json +1 -1
  130. package/skills/building-a-console/SKILL.md +1 -1
  131. package/skills/deploying-a-console/SKILL.md +5 -5
  132. package/skills/deploying-a-console/assets/secrets.example.json +2 -1
  133. package/skills/deploying-a-console/references/access.md +10 -6
  134. package/skills/deploying-a-console/references/resources.md +10 -7
  135. package/skills/working-the-board/SKILL.md +10 -9
  136. package/skills/working-the-board/references/project-rules.md +36 -0
  137. package/src/agent-name.ts +9 -0
  138. package/src/api.ts +37 -0
  139. package/src/cli/cli.ts +253 -24
  140. package/src/cli/format.ts +74 -10
  141. package/src/cli/help.ts +30 -10
  142. package/src/cli/main.ts +5 -0
  143. package/src/client.ts +47 -1
  144. package/src/components/calendar.tsx +242 -101
  145. package/src/components/connect.tsx +12 -6
  146. package/src/components/decision-list.tsx +2 -2
  147. package/src/components/documents.tsx +7 -3
  148. package/src/components/event-feed.tsx +16 -3
  149. package/src/components/people.tsx +244 -35
  150. package/src/components/projects.tsx +31 -8
  151. package/src/components/rules.tsx +167 -0
  152. package/src/components/runs.tsx +4 -2
  153. package/src/components/skills.tsx +22 -11
  154. package/src/components/tokens.tsx +2 -14
  155. package/src/components/ui.tsx +25 -2
  156. package/src/components/work.tsx +69 -43
  157. package/src/connect-page.tsx +0 -1
  158. package/src/console.tsx +531 -67
  159. package/src/data.ts +52 -2
  160. package/src/dates.ts +28 -0
  161. package/src/i18n/en.ts +99 -38
  162. package/src/i18n/ko.ts +95 -38
  163. package/src/index.ts +21 -1
  164. package/src/limits.ts +4 -0
  165. package/src/router.ts +77 -12
  166. package/src/routes.ts +23 -3
  167. package/src/rules.ts +109 -0
  168. package/src/schemas.ts +121 -4
  169. package/src/styles/console.css +253 -13
  170. package/src/vocabulary.ts +30 -0
package/src/cli/cli.ts CHANGED
@@ -9,7 +9,14 @@ import {
9
9
  } from "../client.js";
10
10
  import { addDays, dayOf as dayOfDate, isDay } from "../dates.js";
11
11
  import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
12
- import type { RestClaim, RestDecision, RestPerson, RestTaskPatch } from "../schemas.js";
12
+ import { fillRules, RULES_PATH, RULES_TITLE } from "../rules.js";
13
+ import {
14
+ hostOfLogin,
15
+ type RestClaim,
16
+ type RestDecision,
17
+ type RestPerson,
18
+ type RestTaskPatch,
19
+ } from "../schemas.js";
13
20
  import {
14
21
  COLOR_NAMES,
15
22
  type ColorName,
@@ -19,8 +26,10 @@ import {
19
26
  type PersonRole,
20
27
  PROJECT_ROLES,
21
28
  PROJECT_VISIBILITIES,
29
+ PROVIDER_KEYS,
22
30
  type ProjectRole,
23
31
  type ProjectVisibility,
32
+ type ProviderKey,
24
33
  TASK_PRIORITIES,
25
34
  TASK_STATES,
26
35
  type TaskPriority,
@@ -38,12 +47,15 @@ import {
38
47
  formatDocuments,
39
48
  formatGoal,
40
49
  formatGoalLine,
50
+ formatListingLine,
41
51
  formatMemberLine,
42
52
  formatMore,
43
53
  formatOptions,
44
54
  formatPersonLine,
55
+ formatPersonWork,
45
56
  formatProject,
46
57
  formatProjectLine,
58
+ formatRules,
47
59
  formatRun,
48
60
  formatRunLine,
49
61
  formatSkills,
@@ -102,6 +114,8 @@ export interface CliIo {
102
114
  readonly now: () => number;
103
115
  /** The version of the package the command runs from, when the entry point knows it. */
104
116
  readonly version?: string | undefined;
117
+ /** Where the console's own skills are on this machine, shipped with the package; nothing when they are not there. */
118
+ readonly skillsDir?: string | undefined;
105
119
  /** How `serve` listens on the loopback (ADR-0014); left out, the command cannot serve. */
106
120
  readonly listen?: Listen | undefined;
107
121
  }
@@ -368,6 +382,8 @@ function wordOf<T extends string>(value: string, words: readonly T[], what: stri
368
382
 
369
383
  const personRoleOf = (value: string): PersonRole =>
370
384
  wordOf(value, PERSON_ROLES, "a role in the workspace");
385
+ const providerOf = (value: string): ProviderKey =>
386
+ wordOf(value, PROVIDER_KEYS, "a provider people sign in through");
371
387
  const projectRoleOf = (value: string): ProjectRole =>
372
388
  wordOf(value, PROJECT_ROLES, "a role in a project");
373
389
  const visibilityOf = (value: string): ProjectVisibility =>
@@ -522,7 +538,8 @@ const login: Command = async (args, io) => {
522
538
  // A connection (ADR-0011): a code a person approves on the console's own pages, and the token
523
539
  // handed over here once, never shown.
524
540
  const client = createClient({ baseUrl: url, fetch: io.fetch });
525
- const agent = text(values, "agent") ?? "The console command";
541
+ // "<agent> on <machine>": the pages say the agent and keep the machine for a hover.
542
+ const agent = text(values, "agent") ?? agentOf(io.env) ?? "An agent";
526
543
  const begun = await client.connect({
527
544
  agent: io.hostname === undefined ? agent : `${agent} on ${io.hostname}`,
528
545
  });
@@ -550,11 +567,47 @@ const login: Command = async (args, io) => {
550
567
 
551
568
  const logout: Command = async (args, io) => {
552
569
  parse(args, {}, false);
570
+ const stored = await io.store.load();
571
+ const url = io.env.CONSOLE_URL ?? stored?.url;
572
+ const token = io.env.CONSOLE_TOKEN ?? stored?.token;
573
+ if (url === undefined || token === undefined) {
574
+ io.stdout("Not signed in here: nothing to forget.\n");
575
+ return EXIT.ok;
576
+ }
577
+ // The token is given up at the console first, so that it is good for nothing from now on;
578
+ // one the console no longer knows is gone already. A console out of reach leaves it to the
579
+ // Agents page, and the person is told so.
580
+ let gone = true;
581
+ try {
582
+ await createClient({ baseUrl: url, token, fetch: io.fetch }).giveUpToken();
583
+ } catch (error) {
584
+ if (!(error instanceof ApiError && error.code === "auth.required")) {
585
+ gone = false;
586
+ }
587
+ }
553
588
  await io.store.clear();
554
- io.stdout("Signed out here. The token itself is removed on your Agents page.\n");
589
+ io.stdout(
590
+ gone
591
+ ? "Signed out: the token is given up at the console and forgotten here.\n"
592
+ : `Signed out here, but the console at ${url} could not be reached to give the token up: a person removes it on the Agents page.\n`,
593
+ );
594
+ if (io.env.CONSOLE_TOKEN !== undefined) {
595
+ io.stdout("CONSOLE_TOKEN in the environment still holds it: unset it.\n");
596
+ }
555
597
  return EXIT.ok;
556
598
  };
557
599
 
600
+ /** The agent the command runs under, where its environment says so: what a connection is named by default. */
601
+ function agentOf(env: CliIo["env"]): string | undefined {
602
+ if (env.CLAUDECODE !== undefined) {
603
+ return "Claude Code";
604
+ }
605
+ if (Object.keys(env).some((key) => key.startsWith("CODEX_"))) {
606
+ return "Codex";
607
+ }
608
+ return undefined;
609
+ }
610
+
558
611
  const whoami: Command = async (args, io) => {
559
612
  const { values } = parse(args, {}, false);
560
613
  const session = await open(io, values);
@@ -597,7 +650,7 @@ const use: Command = async (args, io) => {
597
650
  session,
598
651
  project,
599
652
  () =>
600
- `Working in ${project.name} (${project.key}) from this directory on: kept in ${kept}, which is meant to be committed.`,
653
+ `Working in ${project.name} (${project.key}) from this directory on: kept in ${kept}, which is meant to be committed. What its people ask of agents: console rules.`,
601
654
  );
602
655
  return EXIT.ok;
603
656
  };
@@ -653,10 +706,18 @@ const tasks: Command = async (args, io) => {
653
706
  };
654
707
 
655
708
  const work: Command = async (args, io) => {
656
- const { values } = parse(args, {}, false);
709
+ const { values } = parse(args, { person: { type: "string" } }, false);
657
710
  const session = await open(io, values);
658
- const found = await session.client.work();
659
- answer(io, session, found, () => formatWork(found));
711
+ const login = text(values, "person");
712
+ if (login === undefined) {
713
+ const found = await session.client.work();
714
+ answer(io, session, found, () => formatWork(found));
715
+ return EXIT.ok;
716
+ }
717
+ // Another person's plate: the console decides whether it is the asker's to see.
718
+ const whose = await personOf(session, login);
719
+ const found = await session.client.personWork(whose.id);
720
+ answer(io, session, found, () => formatPersonWork(whose, found));
660
721
  return EXIT.ok;
661
722
  };
662
723
 
@@ -675,10 +736,118 @@ const skills: Command = async (args, io) => {
675
736
  const session = await open(io, values);
676
737
  const project = await session.project();
677
738
  const found = await project.skills();
678
- answer(io, session, found, () => formatSkills(found));
739
+ answer(io, session, found, () => formatSkills(found, io.skillsDir));
740
+ return EXIT.ok;
741
+ };
742
+
743
+ /** A version number given as a flag, or nothing; anything else is not understood. */
744
+ function versionOf(given: string | undefined): number | undefined {
745
+ if (given === undefined) {
746
+ return undefined;
747
+ }
748
+ const version = Number(given);
749
+ if (!Number.isInteger(version) || version < 1) {
750
+ throw misuse(`Not a version number: ${given}`);
751
+ }
752
+ return version;
753
+ }
754
+
755
+ // The rules of the board (ADR-0023): read before a task is taken, written by an owner for the
756
+ // project and by an administrator for the workspace, through the same page the console keeps.
757
+ const rules: Command = async (args, io) => {
758
+ const [verb, ...rest] = args;
759
+ if (verb === "write") {
760
+ const { values, positionals } = parse(rest, {
761
+ body: { type: "string" },
762
+ file: { type: "string" },
763
+ workspace: { type: "boolean" },
764
+ base: { type: "string" },
765
+ });
766
+ if (positionals.length > 1) {
767
+ throw misuse("Say the rules as one argument: console rules write --body <markdown>");
768
+ }
769
+ const given = positionals[0] ?? text(values, "body");
770
+ const none = given === "none";
771
+ const body = none ? undefined : await bodyOf(io, values, given);
772
+ if (!none && body === undefined) {
773
+ throw misuse(
774
+ "Say what the rules are to say: --body <markdown>, --file <path>, - to read standard input, or none to put the written rules away.",
775
+ );
776
+ }
777
+ const baseVersion = versionOf(text(values, "base"));
778
+ const session = await open(io, values);
779
+ if (on(values, "workspace")) {
780
+ if (none) {
781
+ const put = await session.client.archiveRules();
782
+ answer(
783
+ io,
784
+ session,
785
+ put,
786
+ () => "The organization's rules are put away: the console's own hold.",
787
+ );
788
+ return EXIT.ok;
789
+ }
790
+ const written = await session.client.writeRules({
791
+ body: body ?? "",
792
+ ...(baseVersion === undefined ? {} : { baseVersion }),
793
+ });
794
+ answer(io, session, written, () =>
795
+ written.page === null
796
+ ? "Nothing written: these are the console's own rules as they stand, and they hold already."
797
+ : `Wrote the workspace's rules, version ${written.page.version}.`,
798
+ );
799
+ return EXIT.ok;
800
+ }
801
+ const project = await session.project();
802
+ if (none) {
803
+ try {
804
+ const put = await project.archiveDocument(RULES_PATH);
805
+ answer(io, session, put, () => "This project's rules are put away: it adds none now.");
806
+ } catch (error) {
807
+ if (!(error instanceof ApiError) || error.code !== "document.not_found") {
808
+ throw error;
809
+ }
810
+ answer(io, session, null, () => "This project has no rules of its own to put away.");
811
+ }
812
+ return EXIT.ok;
813
+ }
814
+ const written = await project.writeDocument(RULES_PATH, {
815
+ title: RULES_TITLE,
816
+ body: body ?? "",
817
+ ...(baseVersion === undefined ? {} : { baseVersion }),
818
+ });
819
+ answer(io, session, written, () => `Wrote this project's rules, version ${written.version}.`);
820
+ return EXIT.ok;
821
+ }
822
+ const { values } = parse(args, {}, false);
823
+ const session = await open(io, values);
824
+ const key = await session.projectKey();
825
+ const [found, me] = await Promise.all([
826
+ key === undefined ? session.client.rules() : session.client.project(key).rules(),
827
+ session.client.me(),
828
+ ]);
829
+ const read =
830
+ "workspace" in found
831
+ ? {
832
+ workspace: forPerson(found.workspace, me.language),
833
+ project: forPerson(found.project, me.language),
834
+ }
835
+ : forPerson(found, me.language);
836
+ answer(io, session, read, () => formatRules(read));
679
837
  return EXIT.ok;
680
838
  };
681
839
 
840
+ /**
841
+ * The rules as they read for the person whose token the command holds: `{language}` filled
842
+ * with the language they chose for the pages, or with the task's when they chose none.
843
+ */
844
+ function forPerson<T extends { readonly body: string | null }>(
845
+ half: T,
846
+ language: string | null,
847
+ ): T {
848
+ return half.body === null ? half : { ...half, body: fillRules(half.body, language) };
849
+ }
850
+
682
851
  /** The flags that set a task's fields, shared by writing one and changing one. */
683
852
  const TASK_FIELDS: OptionSpec = {
684
853
  state: { type: "string" },
@@ -804,12 +973,21 @@ const take: Command = async (args, io) => {
804
973
  taskId: found.id,
805
974
  ...(agent === undefined ? {} : { agent }),
806
975
  });
807
- answer(io, session, { run, task: found }, () =>
976
+ // The rules come with the run, as they read for the person: everything from here acts
977
+ // within them (ADR-0023).
978
+ const [ruling, me] = await Promise.all([project.rules(), session.client.me()]);
979
+ const rules = {
980
+ workspace: forPerson(ruling.workspace, me.language),
981
+ project: forPerson(ruling.project, me.language),
982
+ };
983
+ answer(io, session, { run, task: found, rules }, () =>
808
984
  [
809
985
  `Run ${run.id} began on #${found.number} ${found.title}, as "${run.agent}" for ${run.person.login}. The task is in progress and theirs. A run that was open on it is over now: the board cannot know whether its agent still ran, so taking it was deciding.`,
810
986
  "",
811
987
  formatTask(found),
812
988
  "",
989
+ formatRules(rules),
990
+ "",
813
991
  'Say how it goes: console report "<what you found or did>". Hand in what you make: console hand-in <https url | file path>.',
814
992
  'Ask when a person must decide: console ask "<question>" --option "..." --option "...". End with: console finish --summary "<what was done, what is left>".',
815
993
  ].join("\n"),
@@ -1403,25 +1581,67 @@ const people: Command = async (args, io) => {
1403
1581
  const { values } = parse(args, {}, false);
1404
1582
  const session = await open(io, values);
1405
1583
  const found = await session.client.people();
1406
- answer(io, session, found, () => found.items.map(formatPersonLine).join("\n"));
1584
+ answer(io, session, found, () =>
1585
+ [...found.items.map(formatPersonLine), ...found.listed.map(formatListingLine)].join("\n"),
1586
+ );
1407
1587
  return EXIT.ok;
1408
1588
  };
1409
1589
 
1590
+ const PERSON_USAGE =
1591
+ "Say what to do: console person role <login> <admin|member> | person add <login> [--host gh|google] | person remove <login> | person deactivate <login> | person reactivate <login>";
1592
+
1410
1593
  const person: Command = async (args, io) => {
1411
- if (args[0] !== "role") {
1412
- throw misuse("Say what to do: console person role <login> <admin|member>");
1594
+ const what = args[0];
1595
+ if (what === "role") {
1596
+ const { values, positionals } = parse(args.slice(1), {});
1597
+ const [login, role] = theTwo(
1598
+ positionals,
1599
+ "Say whom and what: console person role <login> <admin|member>",
1600
+ );
1601
+ const wanted = personRoleOf(role);
1602
+ const session = await open(io, values);
1603
+ const found = await personOf(session, login);
1604
+ const changed = await session.client.updatePerson(found.id, { role: wanted });
1605
+ answer(io, session, changed, () => formatPersonLine(changed));
1606
+ return EXIT.ok;
1413
1607
  }
1414
- const { values, positionals } = parse(args.slice(1), {});
1415
- const [login, role] = theTwo(
1416
- positionals,
1417
- "Say whom and what: console person role <login> <admin|member>",
1418
- );
1419
- const wanted = personRoleOf(role);
1420
- const session = await open(io, values);
1421
- const found = await personOf(session, login);
1422
- const changed = await session.client.updatePerson(found.id, { role: wanted });
1423
- answer(io, session, changed, () => formatPersonLine(changed));
1424
- return EXIT.ok;
1608
+ if (what === "add") {
1609
+ const { values, positionals } = parse(args.slice(1), { host: { type: "string" } });
1610
+ const login = theOne(positionals, "Say whom: console person add <login> [--host gh|google]");
1611
+ const chosen = text(values, "host");
1612
+ const host = chosen === undefined ? hostOfLogin(login) : providerOf(chosen);
1613
+ const session = await open(io, values);
1614
+ const made = await session.client.createListing({ host, login });
1615
+ answer(io, session, made, () => formatListingLine(made));
1616
+ return EXIT.ok;
1617
+ }
1618
+ if (what === "remove") {
1619
+ const { values, positionals } = parse(args.slice(1), {});
1620
+ const login = theOne(positionals, "Say whom: console person remove <login>");
1621
+ const session = await open(io, values);
1622
+ const { listed } = await session.client.people();
1623
+ const found = listed.find((listing) => listing.login.toLowerCase() === login.toLowerCase());
1624
+ if (found === undefined) {
1625
+ throw misuse(
1626
+ `Nobody called ${login} is let in and not signed in yet. console people lists them; a person who signed in is taken out with console person deactivate <login>.`,
1627
+ );
1628
+ }
1629
+ await session.client.removeListing(found.id);
1630
+ answer(io, session, { removed: found }, () => `${found.login} is no longer let in.`);
1631
+ return EXIT.ok;
1632
+ }
1633
+ if (what === "deactivate" || what === "reactivate") {
1634
+ const { values, positionals } = parse(args.slice(1), {});
1635
+ const login = theOne(positionals, `Say whom: console person ${what} <login>`);
1636
+ const session = await open(io, values);
1637
+ const found = await personOf(session, login);
1638
+ const changed = await session.client.updatePerson(found.id, {
1639
+ status: what === "deactivate" ? "deactivated" : "active",
1640
+ });
1641
+ answer(io, session, changed, () => formatPersonLine(changed));
1642
+ return EXIT.ok;
1643
+ }
1644
+ throw misuse(PERSON_USAGE);
1425
1645
  };
1426
1646
 
1427
1647
  /** The flags that set what a project is, shared by making one and changing one. */
@@ -1665,6 +1885,7 @@ const COMMANDS: Readonly<Record<string, Command>> = {
1665
1885
  goal,
1666
1886
  categories,
1667
1887
  skills,
1888
+ rules,
1668
1889
  people,
1669
1890
  person,
1670
1891
  project,
@@ -1743,7 +1964,15 @@ function hintFor(error: ApiError): string {
1743
1964
  case "category.in_use":
1744
1965
  return "Tasks are sorted into the category. Move them out first: console task edit <number> --category <other|none>\n";
1745
1966
  case "person.last_admin":
1746
- return "The board keeps at least one administrator. Make another one first.\n";
1967
+ return "The board keeps at least one administrator in. Make another one first.\n";
1968
+ case "person.of_organization":
1969
+ return "The organization says who is in: a person it let in comes and goes with it, and is listed by login to stay.\n";
1970
+ case "listing.exists":
1971
+ return "The login is let in already, or is a person's who is in. console people lists both.\n";
1972
+ case "listing.not_found":
1973
+ return "No login let in and not signed in yet has that id. console people lists them.\n";
1974
+ case "listing.unknown_host":
1975
+ return "Nobody signs in through that provider here. console whoami --json names the ways in.\n";
1747
1976
  case "connect.too_many":
1748
1977
  return "The console holds as many connections as it may at once. Try again in a few minutes.\n";
1749
1978
  case "network":
package/src/cli/format.ts CHANGED
@@ -6,9 +6,13 @@ import type {
6
6
  RestDocumentSummary,
7
7
  RestDocuments,
8
8
  RestGoal,
9
+ RestListing,
9
10
  RestMember,
10
11
  RestPerson,
12
+ RestPersonWork,
11
13
  RestProject,
14
+ RestProjectRules,
15
+ RestRules,
12
16
  RestRun,
13
17
  RestSkills,
14
18
  RestTask,
@@ -57,11 +61,20 @@ export function formatProject(project: RestProject): string {
57
61
  return lines.join("\n");
58
62
  }
59
63
 
60
- /** One person: their login, their name where the host gives one, and what they are in the workspace. */
64
+ /** One person: their login, their name where the host gives one, what they are in the workspace, and that they are out when they are. */
61
65
  export function formatPersonLine(person: RestPerson): string {
66
+ const standing = person.status === "active" ? person.role : `${person.role}, deactivated`;
62
67
  return person.name === null
63
- ? `${person.login} (${person.role})`
64
- : `${person.login} ${person.name} (${person.role})`;
68
+ ? `${person.login} (${standing})`
69
+ : `${person.login} ${person.name} (${standing})`;
70
+ }
71
+
72
+ /** The provider a listing names, as people call it. */
73
+ const HOST_NAMES = { gh: "GitHub", google: "Google" } as const;
74
+
75
+ /** One login let in that nobody has signed in with yet (ADR-0022). */
76
+ export function formatListingLine(listing: RestListing): string {
77
+ return `${listing.login} (let in at ${HOST_NAMES[listing.host]}, not signed in yet)`;
65
78
  }
66
79
 
67
80
  /** One member of a project: their login, what they are in it, and since when. */
@@ -203,17 +216,27 @@ const SKILLS_WORDS: Readonly<Record<Exclude<RestSkills["status"], "none" | "read
203
216
  unavailable: "SkillCDN could not be reached; ask again later.",
204
217
  };
205
218
 
206
- /** The organization's skills: where they are, and each with what an agent loads it by. */
207
- export function formatSkills(skills: RestSkills): string {
208
- if (skills.status === "none" || skills.address === null) {
209
- return "No skills address: neither this project nor the console names one.";
219
+ /**
220
+ * The organization's skills: where they are, each with what an agent loads it by, where a
221
+ * person connects an agent that has no connection to the address, and where the console's own
222
+ * skills are on this machine, when the command knows.
223
+ */
224
+ export function formatSkills(skills: RestSkills, ownSkills?: string): string {
225
+ const own =
226
+ ownSkills === undefined
227
+ ? []
228
+ : ["", `The console's own skills, for working the board: ${ownSkills}`];
229
+ if (skills.status === "none" || skills.address === null || skills.page === null) {
230
+ return ["No skills address: neither this project nor the console names one.", ...own].join(
231
+ "\n",
232
+ );
210
233
  }
211
234
  const head = `skills at ${skills.address}, served by ${skills.source}: ${skills.status}`;
212
235
  if (skills.status !== "ready") {
213
- return `${head}\n${SKILLS_WORDS[skills.status]}`;
236
+ return [head, SKILLS_WORDS[skills.status], ...own].join("\n");
214
237
  }
215
238
  if (skills.items.length === 0) {
216
- return `${head}\nNo skills at this address.`;
239
+ return [head, "No skills at this address.", ...own].join("\n");
217
240
  }
218
241
  const lines = [head];
219
242
  for (const skill of skills.items) {
@@ -221,11 +244,41 @@ export function formatSkills(skills: RestSkills): string {
221
244
  }
222
245
  lines.push(
223
246
  "",
224
- "Load a skill by its URI through your SkillCDN connection. --json adds the page of each, for a person.",
247
+ `Load a skill by its URI through your SkillCDN connection to this address; an agent without one is connected by a person at ${skills.page}. --json adds the page of each, for a person.`,
248
+ ...own,
225
249
  );
226
250
  return lines.join("\n");
227
251
  }
228
252
 
253
+ /** One half of the rules: where it comes from, and the text as it stands. */
254
+ function formatRulesHalf(where: "the workspace" | "this project", rules: RestRules): string {
255
+ const page = rules.page;
256
+ const facts =
257
+ page === null
258
+ ? undefined
259
+ : `v${page.version}, by ${writer(page.updatedBy, page.agent)}; ${page.updatedAt}`;
260
+ if (rules.source === "none" || rules.body === null) {
261
+ return `Rules of ${where}: none of its own; the workspace's hold.`;
262
+ }
263
+ const head =
264
+ rules.source === "reference"
265
+ ? `Rules of ${where}: the console's own, until an administrator writes the organization's (console rules write --workspace).`
266
+ : `Rules of ${where} (${facts ?? "written"}):`;
267
+ return `${head}\n\n${rules.body.trimEnd()}`;
268
+ }
269
+
270
+ /** The rules that hold: the workspace's, and what the project adds when the command works in one. */
271
+ export function formatRules(rules: RestProjectRules | RestRules): string {
272
+ if ("workspace" in rules) {
273
+ return [
274
+ formatRulesHalf("the workspace", rules.workspace),
275
+ "",
276
+ formatRulesHalf("this project", rules.project),
277
+ ].join("\n");
278
+ }
279
+ return formatRulesHalf("the workspace", rules);
280
+ }
281
+
229
282
  /** How far a goal is, from its tasks: the done, the at work, and the rest, in one phrase. */
230
283
  function progressOf(goal: RestGoal): string {
231
284
  const counts = goal.taskCounts;
@@ -282,6 +335,17 @@ export function formatWork(work: RestWork): string {
282
335
  return lines.join("\n");
283
336
  }
284
337
 
338
+ /** Another person's plate: their open tasks, each with its project; no decisions, which wait for anyone. */
339
+ export function formatPersonWork(person: RestPerson, work: RestPersonWork): string {
340
+ if (work.tasks.length === 0) {
341
+ return `No open task is assigned to ${person.login}.`;
342
+ }
343
+ return [
344
+ `tasks assigned to ${person.login}:`,
345
+ ...work.tasks.map(({ project, task }) => ` ${project.key} ${formatTaskLine(task)}`),
346
+ ].join("\n");
347
+ }
348
+
285
349
  export function formatDecisionLine(decision: RestDecision): string {
286
350
  const state = decision.answer === null ? "waiting" : `answered ${decision.answer.option}`;
287
351
  return `${decision.id} ${state} ${decision.question} (asked by ${asker(decision)})`;
package/src/cli/help.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  MAX_PROJECT_SYMBOL_LENGTH,
21
21
  MAX_QUESTION_LENGTH,
22
22
  MAX_REPORTS_PER_RUN,
23
+ MAX_RULES_LENGTH,
23
24
  MAX_SUMMARY_LENGTH,
24
25
  MAX_TITLE_LENGTH,
25
26
  MAX_VERSIONS_PER_DOCUMENT,
@@ -83,7 +84,7 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
83
84
  login: {
84
85
  usage: "login --url <origin> [--agent <name>] [--token-stdin]",
85
86
  about:
86
- "Connects this agent to the console: shows an address and a code, which a person opens signed in, names the agent and approves; the command then receives a token of its own and keeps it, with the console's address, in your home directory, never showing it. An agent running the command relays the address and the code to its person and waits. --agent says what the agent calls itself. --token-stdin reads a token made by hand from standard input instead, for a script or a job.",
87
+ "Connects this agent to the console: shows an address and a code, which a person opens signed in, names the agent and approves; the command then receives a token of its own and keeps it, with the console's address, in your home directory, never showing it. An agent running the command relays the address and the code to its person and waits. --agent says what the agent calls itself; left out, the command names the agent it runs under where its environment says so (Claude Code, Codex), else An agent; the machine's name follows, and the pages show the name before 'on' with the whole on hover. --token-stdin reads a token made by hand from standard input instead, for a script or a job.",
87
88
  refusals: [
88
89
  "connect.not_found: the code was not approved in time, or the person said it was not theirs; run login again",
89
90
  "the console does not know the token given: make one on the Agents page and try again",
@@ -92,7 +93,7 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
92
93
  logout: {
93
94
  usage: "logout",
94
95
  about:
95
- "Forgets the address and the token kept here. The token itself is removed on the Agents page.",
96
+ "Signs this agent out: gives the token up at the console, so that it is good for nothing from now on, and forgets it with the address kept here. A console out of reach leaves the token to the Agents page.",
96
97
  },
97
98
  whoami: {
98
99
  usage: "whoami",
@@ -111,9 +112,9 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
111
112
  refusals: ["project.not_found: no project has the key, or it is not yours to see"],
112
113
  },
113
114
  work: {
114
- usage: "work",
115
+ usage: "work [--person <login>]",
115
116
  about:
116
- "What is on your plate across the projects you may see: the open tasks assigned to you, the sooner due first, each with its project; and the decisions waiting for a person. Needs no project.",
117
+ "What is on your plate across the projects you may see: the open tasks assigned to you, the sooner due first, each with its project; and the decisions waiting for a person. Needs no project. --person <login> shows another person's plate, for an administrator or for an owner of a project they are in: their open tasks within the projects you may see, without the decisions, which wait for anyone.",
117
118
  },
118
119
  tasks: {
119
120
  usage:
@@ -159,15 +160,19 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
159
160
  people: {
160
161
  usage: "people",
161
162
  about:
162
- "Everyone of the workspace, by login, with their name where the host gives one and what they are in it: admin, who configures the workspace, or member, who works in the projects they are in. The logins are what --assignee, member and person role take.",
163
+ "Everyone of the workspace, by login, with their name where the host gives one and what they are in it: admin, who configures the workspace, or member, who works in the projects they are in; a person taken out is marked deactivated. Then the logins an administrator let in that nobody has signed in with yet. The logins are what --assignee, member and person take.",
163
164
  },
164
165
  person: {
165
- usage: "person role <login> <admin|member>",
166
+ usage:
167
+ "person role <login> <admin|member> | person add <login> [--host gh|google] | person remove <login> | person deactivate <login> | person reactivate <login>",
166
168
  about:
167
- "Says what a person is in the workspace, for an administrator: the person themselves on the People page, or their agent here, named on it. The board keeps at least one administrator.",
169
+ "For an administrator: the person themselves on the People page, or their agent here, named on it. role says what a person is in the workspace; the board keeps at least one administrator in. add lets a login in: whoever signs in with that account at that host is a person from then on; the host is read off the login (an address is Google's, anything else GitHub's) unless --host says. remove takes a login away that nobody has signed in with yet. deactivate takes a person out: refused on every request at once, their agents with them, kept with everything they did, and listed nowhere; reactivate takes them back in. A person the organization let in comes and goes with it, and is neither.",
168
170
  refusals: [
169
- "auth.forbidden: only an administrator says what a person is",
170
- "person.last_admin: the board keeps at least one administrator; make another one first",
171
+ "auth.forbidden: only an administrator says who is in and what they are",
172
+ "person.last_admin: the board keeps at least one administrator in; make another one first",
173
+ "person.of_organization: the organization says whether that person is in",
174
+ "listing.exists: the login is let in already, or is a person's who is in",
175
+ "listing.unknown_host: nobody signs in through that provider here",
171
176
  ],
172
177
  },
173
178
  project: {
@@ -228,7 +233,20 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
228
233
  skills: {
229
234
  usage: "skills",
230
235
  about:
231
- "The organization's skills, as SkillCDN serves them at the console's address: each with its description and the URI you load it by through your own SkillCDN connection. --json adds the page of each, for a person.",
236
+ "The organization's skills, as SkillCDN serves them at the project's address: each with its description and the URI you load it by through your own SkillCDN connection, which a person makes at the address's page when you have none; and where the console's own skills are on this machine. --json adds the page of each, for a person.",
237
+ },
238
+ rules: {
239
+ usage:
240
+ "rules | rules write --body <markdown>|--file <path>|-|none [--workspace] [--base <version>]",
241
+ about:
242
+ "What this organization asks of agents (ADR-0023): the workspace's rules, the console's own until an administrator writes the organization's, and this project's own when it has them. Read them before taking a task; take prints them again. rules write writes this project's rules, for an owner, or with --workspace the workspace's, for an administrator; none puts the written rules away, so that the console's own hold again, or the project adds none. Without a project named, rules shows the workspace's alone.",
243
+ limits: [
244
+ `body: Markdown, up to ${MAX_RULES_LENGTH} characters; the console's own rules, written as they are, write nothing`,
245
+ ],
246
+ refusals: [
247
+ "auth.forbidden: an owner writes a project's rules, an administrator the workspace's",
248
+ "document.conflict: the rules have moved on since --base; read them again",
249
+ ],
232
250
  },
233
251
  take: {
234
252
  usage: "take <number|id> [--agent <name>]",
@@ -384,6 +402,7 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
384
402
  const preamble = (version: string | undefined): string =>
385
403
  `console${version === undefined ? "" : ` ${version}`}: the board of your organization's work with AI agents, from the command line.
386
404
  You act as the person whose token you hold; everything you send is shown to people as text.
405
+ The rules of this organization come with the task: console take prints them; console rules reads them any time.
387
406
 
388
407
  Usage: console <command> [arguments] [--json] [--url <origin>] [--project <key>]
389
408
  `;
@@ -425,6 +444,7 @@ const SECTIONS: readonly { readonly title: string; readonly commands: readonly s
425
444
  "runs",
426
445
  "run",
427
446
  "skills",
447
+ "rules",
428
448
  ],
429
449
  },
430
450
  { title: "Working", commands: ["take", "report", "hand-in", "ask", "finish", "fail", "abandon"] },
package/src/cli/main.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
2
3
  import { createRequire } from "node:module";
3
4
  import { hostname } from "node:os";
4
5
  import process from "node:process";
6
+ import { fileURLToPath } from "node:url";
5
7
  import * as z from "zod/mini";
6
8
  import { runCli } from "./cli.js";
7
9
  import { createFileStore, defaultConfigDir } from "./credentials.js";
@@ -16,6 +18,8 @@ import { readBytesAt, readFileAt, readStdin } from "./terminal.js";
16
18
  const manifest = z
17
19
  .object({ version: z.string() })
18
20
  .safeParse(createRequire(import.meta.url)("../../package.json"));
21
+ /** The console's own skills, shipped with the package beside dist/ (ADR-0015); not there in a checkout before packing. */
22
+ const skillsDir = fileURLToPath(new URL("../../skills/", import.meta.url));
19
23
 
20
24
  process.exitCode = await runCli(process.argv.slice(2), {
21
25
  env: process.env,
@@ -35,5 +39,6 @@ process.exitCode = await runCli(process.argv.slice(2), {
35
39
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
36
40
  now: () => Date.now(),
37
41
  version: manifest.success ? manifest.data.version : undefined,
42
+ skillsDir: existsSync(skillsDir) ? skillsDir : undefined,
38
43
  listen: listenOn,
39
44
  });