toolroll 0.9.83 → 0.9.85

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 (137) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +94 -1192
  3. package/dist/agent-fence.d.ts +3 -3
  4. package/dist/agent-fence.js +3 -3
  5. package/dist/agent-onboard.d.ts +2 -0
  6. package/dist/agent-onboard.js +3 -0
  7. package/dist/assignment-ui.js +5 -1
  8. package/dist/browser/workspace.css +2 -2
  9. package/dist/browser/workspace.js +31 -31
  10. package/dist/browser-crew.js +5 -1
  11. package/dist/browser-workspace.d.ts +12 -0
  12. package/dist/chat-channel.d.ts +10 -1
  13. package/dist/chat-channel.js +35 -5
  14. package/dist/chat-decide.d.ts +16 -4
  15. package/dist/chat-decide.js +239 -31
  16. package/dist/chat-messages.d.ts +5 -0
  17. package/dist/chat-messages.js +1 -1
  18. package/dist/chat-quiet.js +3 -2
  19. package/dist/chat-review.d.ts +4 -0
  20. package/dist/chat-review.js +13 -4
  21. package/dist/chat-terms.d.ts +9 -0
  22. package/dist/chat-terms.js +46 -0
  23. package/dist/contracts/console-api.d.ts +4 -2
  24. package/dist/contracts/console-api.js +7 -2
  25. package/dist/contracts/lead-tools.d.ts +30 -0
  26. package/dist/contracts/lead-tools.js +3 -0
  27. package/dist/contracts/youiyoux-request.d.ts +248 -0
  28. package/dist/contracts/youiyoux-request.js +152 -0
  29. package/dist/control-setup.d.ts +9 -0
  30. package/dist/control-setup.js +31 -4
  31. package/dist/control-ui.d.ts +7 -1
  32. package/dist/control-ui.js +3 -2
  33. package/dist/desktop-update.d.ts +1 -1
  34. package/dist/dispatch.d.ts +15 -0
  35. package/dist/dispatch.js +26 -4
  36. package/dist/first-run.d.ts +5 -2
  37. package/dist/first-run.js +13 -2
  38. package/dist/flow-cadence.d.ts +8 -2
  39. package/dist/flow-cadence.js +16 -3
  40. package/dist/flow-card-task.d.ts +9 -0
  41. package/dist/flow-card-task.js +15 -6
  42. package/dist/folder-look.d.ts +3 -0
  43. package/dist/folder-look.js +8 -4
  44. package/dist/guides.js +4 -3
  45. package/dist/integrations-ui.js +9 -4
  46. package/dist/integrations.d.ts +9 -1
  47. package/dist/integrations.js +43 -2
  48. package/dist/landed-cleanup.d.ts +4 -0
  49. package/dist/landed-cleanup.js +3 -2
  50. package/dist/lead-capabilities.js +2 -1
  51. package/dist/lead-cli.js +3 -1
  52. package/dist/lead-tools.d.ts +2 -0
  53. package/dist/lead-tools.js +30 -4
  54. package/dist/lead.d.ts +2 -0
  55. package/dist/lead.js +3 -3
  56. package/dist/model-catalog.d.ts +5 -0
  57. package/dist/model-catalog.js +11 -2
  58. package/dist/model-choices.d.ts +188 -0
  59. package/dist/model-choices.js +405 -0
  60. package/dist/model-names.d.ts +6 -0
  61. package/dist/model-names.js +34 -0
  62. package/dist/models-ui.d.ts +69 -5
  63. package/dist/models-ui.js +53 -10
  64. package/dist/needs-you.d.ts +8 -0
  65. package/dist/needs-you.js +8 -0
  66. package/dist/operate.d.ts +22 -2
  67. package/dist/operate.js +107 -41
  68. package/dist/project-activity.d.ts +20 -0
  69. package/dist/project-activity.js +23 -0
  70. package/dist/project-admission.d.ts +4 -1
  71. package/dist/project-admission.js +23 -2
  72. package/dist/project-cli.d.ts +1 -0
  73. package/dist/project-cli.js +36 -19
  74. package/dist/project-identity.d.ts +9 -2
  75. package/dist/project-identity.js +80 -33
  76. package/dist/project-knowledge.d.ts +9 -9
  77. package/dist/project-outcomes.d.ts +119 -0
  78. package/dist/project-outcomes.js +79 -0
  79. package/dist/result-actions.d.ts +4 -0
  80. package/dist/result-actions.js +41 -9
  81. package/dist/result-review.d.ts +9 -0
  82. package/dist/result-review.js +45 -5
  83. package/dist/serve.js +60 -13
  84. package/dist/server/chat.js +5 -4
  85. package/dist/server/chrome.d.ts +3 -0
  86. package/dist/server/chrome.js +5 -2
  87. package/dist/server/console.css +17 -2
  88. package/dist/server/flows.js +4 -0
  89. package/dist/server/http.d.ts +1 -0
  90. package/dist/server/http.js +6 -0
  91. package/dist/server/pages.js +53 -7
  92. package/dist/server/people-tokens.js +40 -20
  93. package/dist/server/render-chat.js +3 -2
  94. package/dist/server/render-pages.d.ts +12 -2
  95. package/dist/server/render-pages.js +36 -16
  96. package/dist/server/render-people.d.ts +14 -2
  97. package/dist/server/render-people.js +35 -12
  98. package/dist/server/render-results.d.ts +2 -0
  99. package/dist/server/render-results.js +20 -7
  100. package/dist/server/render-settings.js +1 -0
  101. package/dist/server/render-tasks.d.ts +1 -0
  102. package/dist/server/render-tasks.js +22 -19
  103. package/dist/server/request-context.d.ts +2 -0
  104. package/dist/server/route-table.js +9 -1
  105. package/dist/server/settings.d.ts +58 -1
  106. package/dist/server/settings.js +216 -70
  107. package/dist/server/signup-gate.d.ts +9 -0
  108. package/dist/server/signup-gate.js +40 -0
  109. package/dist/server/task-views.js +6 -0
  110. package/dist/server/tasks.js +54 -8
  111. package/dist/settings-kit-script.d.ts +21 -0
  112. package/dist/settings-kit-script.js +67 -0
  113. package/dist/settings-kit.d.ts +137 -0
  114. package/dist/settings-kit.js +186 -0
  115. package/dist/settings-proofs.d.ts +64 -0
  116. package/dist/settings-proofs.js +101 -0
  117. package/dist/setup-guide.js +4 -2
  118. package/dist/spend-report.d.ts +87 -0
  119. package/dist/spend-report.js +265 -0
  120. package/dist/spend-ui.d.ts +29 -8
  121. package/dist/spend-ui.js +74 -39
  122. package/dist/store.d.ts +46 -3
  123. package/dist/store.js +165 -6
  124. package/dist/task-sizing.d.ts +5 -3
  125. package/dist/task-sizing.js +4 -4
  126. package/dist/task-source.d.ts +13 -1
  127. package/dist/task-source.js +17 -1
  128. package/dist/telegram-lead.js +7 -5
  129. package/dist/telegram.js +1 -1
  130. package/dist/toolroll-update.js +2 -2
  131. package/dist/webhooks.d.ts +6 -0
  132. package/dist/webhooks.js +25 -0
  133. package/dist/work-index.d.ts +4 -0
  134. package/dist/work-index.js +32 -0
  135. package/dist/youiyoux-intake.d.ts +234 -0
  136. package/dist/youiyoux-intake.js +908 -0
  137. package/package.json +11 -8
@@ -21,7 +21,7 @@ export type AdmissionActor = {
21
21
  };
22
22
  export type AdmissionFailure = {
23
23
  ok: false;
24
- reason: "unavailable" | "not-git" | "locked" | "registry" | "abandoned" | "failed";
24
+ reason: "unavailable" | "not-git" | "locked" | "registry" | "abandoned" | "failed" | "moved";
25
25
  message: string;
26
26
  };
27
27
  /** `remote`: the origin URL this add recorded as the project's trusted remote (null: none), and why one wasn't. */
@@ -60,6 +60,9 @@ export declare function admitProject(store: Store, input: {
60
60
  captureOrigin?: boolean;
61
61
  /** Explicit value from the owner/instance-operator CLI boundary. */
62
62
  remoteUrl?: string;
63
+ /** The caller's authority, asked again about the proved repo inside the locked registry update and inside the
64
+ * database transaction (the console: nothing it proved before an await moved, and the repo is still visible). */
65
+ stillAllowed?: (repo: string) => boolean;
63
66
  }): Promise<Admitted | AdmissionFailure>;
64
67
  /**
65
68
  * Remove one project from the lists and the builder. Its tasks, results and
@@ -61,13 +61,29 @@ export async function admitProject(store, input) {
61
61
  ? await originOf(repo, input.exec ?? run) : null;
62
62
  let remote = { url: store.projectRemote(repo), why: null };
63
63
  let registryHad = true;
64
+ const allowed = () => input.stillAllowed === undefined || input.stillAllowed(repo);
65
+ const moved = { ok: false, reason: "moved", message: `${projectName(repo)} wasn't added: a project folder changed while it was being checked. Try again.` };
64
66
  if (input.registryFile !== null) {
65
- const enrolled = await updateRepos(input.registryFile, repos => addRepos(repos, [repo]));
67
+ let refused = false;
68
+ let had = false;
69
+ const enrolled = await updateRepos(input.registryFile, repos => {
70
+ had = repos.includes(repo);
71
+ if (!allowed()) {
72
+ refused = true;
73
+ return repos;
74
+ }
75
+ return addRepos(repos, [repo]);
76
+ });
66
77
  if (!enrolled.ok)
67
78
  return { ok: false, reason: enrolled.reason, message: `${projectName(repo)} wasn't added: ${enrolled.message}` };
68
- registryHad = before !== null && !("error" in before) && before.repos.includes(repo);
79
+ if (refused)
80
+ return moved;
81
+ registryHad = had;
69
82
  }
70
83
  const added = store.transact(() => {
84
+ // Re-asked with the write; a refusal writes nothing here and takes back the registry line this call added below.
85
+ if (!allowed())
86
+ return null;
71
87
  const rowHad = store.listProjects().some(one => one.path === repo);
72
88
  const previous = store.projectRemote(repo);
73
89
  const first = !known && !store.projectAdmissionKnown(repo);
@@ -81,6 +97,11 @@ export async function admitProject(store, input) {
81
97
  store.recordAction({ at: input.now.toISOString(), actor: input.actor.label, repo, taskId: null, runId: null, action: PROJECT_ADDED, outcome: "added", source: "access", detail: `from ${input.actor.origin}` });
82
98
  return true;
83
99
  });
100
+ if (added === null) {
101
+ if (input.registryFile !== null && !registryHad)
102
+ await updateRepos(input.registryFile, repos => removeRepos(repos, [repo]));
103
+ return moved;
104
+ }
84
105
  return { ok: true, repo, name: projectName(repo), added, remote };
85
106
  }
86
107
  /**
@@ -12,6 +12,7 @@ export declare function runProjectCommand(positional: readonly string[], flags:
12
12
  write: (line: string) => void;
13
13
  json: boolean;
14
14
  registryFile?: string;
15
+ now?: Date;
15
16
  }): Promise<number>;
16
17
  /** Only known credential-aware adapters consume the remembered reference.
17
18
  * Explicit credentials always win; no legacy action gains implicit authority. */
@@ -8,6 +8,7 @@ import { configPath, loadRepos } from './repos.js';
8
8
  import { authenticateCoordinator } from './coordinator.js';
9
9
  import { canonicalProject } from './project.js';
10
10
  import { envelopeJson } from './envelope.js';
11
+ import { projectOutcomes } from './project-outcomes.js';
11
12
  export const projectProfilePath = (databaseFile) => join(dirname(databaseFile), 'cli-project.json');
12
13
  export function readProjectProfile(databaseFile) {
13
14
  const file = projectProfilePath(databaseFile);
@@ -25,11 +26,29 @@ export function readProjectProfile(databaseFile) {
25
26
  throw new Error('The saved CLI project is invalid. Select it again with project use.');
26
27
  return { version: 1, repo: p['repo'], tokenFile: p['tokenFile'] };
27
28
  }
29
+ function scopedCredentialRefusal(store, tokenFile, repo) {
30
+ let token;
31
+ try {
32
+ const info = statSync(tokenFile);
33
+ if (!info.isFile() || info.size > 16_384)
34
+ return { reason: 'unauthenticated', message: 'The scoped credential file is unavailable.' };
35
+ token = readFileSync(tokenFile, 'utf8').trim();
36
+ }
37
+ catch {
38
+ return { reason: 'unauthenticated', message: 'The scoped credential file is unavailable.' };
39
+ }
40
+ const auth = authenticateCoordinator(store, token);
41
+ if (!auth.ok)
42
+ return { reason: 'unauthenticated', message: 'The scoped credential is invalid or revoked.' };
43
+ if (!auth.who.repos.includes(repo))
44
+ return { reason: 'not-found', message: 'That project is unavailable to this credential.' };
45
+ return null;
46
+ }
28
47
  export async function runProjectCommand(positional, flags, context) {
29
48
  const action = positional[0] ?? 'show', command = `project ${action}`;
30
49
  const fail = (reason, message) => { context.write(context.json ? envelopeJson({ ok: false, command, reason, message }) : message); return reason === 'usage' ? 2 : 3; };
31
- if (!['use', 'show'].includes(action) || positional.length > (action === 'use' ? 2 : 1))
32
- return fail('usage', 'Use project use <saved checkout path> [--token-file PATH], or project show.');
50
+ if (!['use', 'show', 'activity'].includes(action) || positional.length > (action === 'use' ? 2 : 1))
51
+ return fail('usage', 'Use project use <saved checkout path> [--token-file PATH], project show, or project activity [--json].');
33
52
  const allowed = new Set(['db', 'json', ...(action === 'use' ? ['token-file'] : [])]);
34
53
  for (const name of flags.keys())
35
54
  if (!allowed.has(name))
@@ -57,23 +76,9 @@ export async function runProjectCommand(positional, flags, context) {
57
76
  if (credential !== undefined && (typeof credential !== 'string' || !credential || /[\x00-\x1f]/.test(credential)))
58
77
  return fail('usage', '--token-file requires a path.');
59
78
  const tokenFile = typeof credential === 'string' ? resolve(credential) : null;
60
- if (tokenFile !== null) {
61
- let token;
62
- try {
63
- const info = statSync(tokenFile);
64
- if (!info.isFile() || info.size > 16_384)
65
- return fail('unauthenticated', 'The scoped credential file is unavailable.');
66
- token = readFileSync(tokenFile, 'utf8').trim();
67
- }
68
- catch {
69
- return fail('unauthenticated', 'The scoped credential file is unavailable.');
70
- }
71
- const auth = authenticateCoordinator(context.store, token);
72
- if (!auth.ok)
73
- return fail('unauthenticated', 'The scoped credential is invalid or revoked.');
74
- if (!auth.who.repos.includes(repo))
75
- return fail('not-found', 'That project is unavailable to this credential.');
76
- }
79
+ const refused = tokenFile === null ? null : scopedCredentialRefusal(context.store, tokenFile, repo);
80
+ if (refused)
81
+ return fail(refused.reason, refused.message);
77
82
  profile = { version: 1, repo, tokenFile };
78
83
  const file = projectProfilePath(context.databaseFile), temp = `${file}.${randomBytes(6).toString('hex')}.tmp`;
79
84
  mkdirSync(dirname(file), { recursive: true });
@@ -82,6 +87,18 @@ export async function runProjectCommand(positional, flags, context) {
82
87
  }
83
88
  else
84
89
  profile = readProjectProfile(context.databaseFile);
90
+ if (action === 'activity') {
91
+ if (profile === null || !repos.includes(profile.repo))
92
+ return fail('not-found', 'Select a saved project with project use first.');
93
+ const refused = profile.tokenFile === null ? null : scopedCredentialRefusal(context.store, profile.tokenFile, profile.repo);
94
+ if (refused)
95
+ return fail(refused.reason, refused.message);
96
+ // Like assignment brief, an uncredentialed local database read has the
97
+ // local operator's access. A saved scoped credential is always re-proved.
98
+ const result = projectOutcomes(context.store, profile.repo, context.now ?? new Date(), { principal: 'operator', repos: [profile.repo] });
99
+ context.write(context.json ? envelopeJson({ ok: true, command, result }) : JSON.stringify(result, null, 2));
100
+ return 0;
101
+ }
85
102
  const result = { profile, saved: profile !== null && repos.includes(profile.repo) };
86
103
  context.write(context.json ? envelopeJson({ ok: true, command, result }) : profile === null ? 'No project selected. Use project use <saved checkout path>.' : `${profile.repo}${result.saved ? '' : ' (no longer saved)'}\n${profile.tokenFile === null ? 'Uses your existing local sign-in for operator actions.' : `Scoped credential reference: ${profile.tokenFile}`}`);
87
104
  return 0;
@@ -2,7 +2,8 @@
2
2
  * Project identities as the console uses them, without touching a person's folders on its thread.
3
3
  *
4
4
  * A project's identity is its real path (project.ts canonicalProject). Resolving one reads the folder, and a folder can
5
- * stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread:
5
+ * stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread, and
6
+ * never waits for one before it answers:
6
7
  *
7
8
  * - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
8
9
  * - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
@@ -10,7 +11,8 @@
10
11
  * - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
11
12
  * Anything else is refused this time and resolved by the next background pass, never resolved inline.
12
13
  * - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
13
- * - `refresh` (the console's start and its background pass): resolves what was asked about and what has gone stale.
14
+ * - `refresh` (the console's background pass, the first started right after it listens): resolves what was asked about
15
+ * and what has gone stale. The console answers before any of it does; until a path answers it fails closed.
14
16
  *
15
17
  * An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
16
18
  */
@@ -31,7 +33,12 @@ export type ProjectIdentities = {
31
33
  resolveNow: (path: string) => Promise<string | null | "no-answer">;
32
34
  /** Resolve `paths`, everything asked about since, and answers older than a minute. False when some didn't answer. */
33
35
  refresh: (paths: Iterable<string>) => Promise<boolean>;
36
+ /** Moves whenever a known identity changes or goes: a decision made before an await is re-checked against it right
37
+ * before its write. */
38
+ generation: () => number;
34
39
  };
40
+ /** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
41
+ export declare const IDENTITY_LOOKS = 4;
35
42
  export declare function projectIdentities(look?: FolderLookOptions, now?: () => number): ProjectIdentities;
36
43
  /** The synchronous reads, as before: for embedders and tests that pass no identities. */
37
44
  export declare function syncProjectIdentities(): ProjectIdentities;
@@ -2,7 +2,8 @@
2
2
  * Project identities as the console uses them, without touching a person's folders on its thread.
3
3
  *
4
4
  * A project's identity is its real path (project.ts canonicalProject). Resolving one reads the folder, and a folder can
5
- * stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread:
5
+ * stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread, and
6
+ * never waits for one before it answers:
6
7
  *
7
8
  * - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
8
9
  * - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
@@ -10,7 +11,8 @@
10
11
  * - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
11
12
  * Anything else is refused this time and resolved by the next background pass, never resolved inline.
12
13
  * - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
13
- * - `refresh` (the console's start and its background pass): resolves what was asked about and what has gone stale.
14
+ * - `refresh` (the console's background pass, the first started right after it listens): resolves what was asked about
15
+ * and what has gone stale. The console answers before any of it does; until a path answers it fails closed.
14
16
  *
15
17
  * An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
16
18
  */
@@ -19,27 +21,86 @@ import { lookIdentities } from "./folder-look.js";
19
21
  import { canonicalProject } from "./project.js";
20
22
  /** How long an answer stands before the background pass resolves the path again. */
21
23
  const STALE_MS = 60_000;
24
+ /** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
25
+ export const IDENTITY_LOOKS = 4;
22
26
  export function projectIdentities(look = {}, now = Date.now) {
27
+ /** Each answer with when the look that answered it started (folder-look.ts `started`, kept across joined looks): an
28
+ * answer from a look started earlier never overwrites one from a look started later. */
23
29
  const answers = new Map();
24
30
  const wanted = new Set();
31
+ /** Paths whose last look didn't answer: looked at only on their own (joining a look still under way), never in a
32
+ * batch, so one folder that never answers holds one child, not one per differently made-up batch. */
33
+ const suspect = new Set();
34
+ let generation = 0;
25
35
  const known = (path) => answers.get(resolve(path))?.real;
26
- const store = (found) => {
36
+ const put = (key, real, at, seq) => {
37
+ const before = answers.get(key);
38
+ // A known identity that changes (or goes) moves authority: anything decided on the old one is out of date.
39
+ if (before !== undefined && before.real !== real)
40
+ generation++;
41
+ answers.set(key, { real, at, seq });
42
+ };
43
+ const store = (found, seq) => {
27
44
  const at = now();
28
45
  for (const [path, real] of found) {
29
- answers.set(resolve(path), { real, at });
30
- wanted.delete(resolve(path));
46
+ const key = resolve(path);
47
+ // Superseded: a later lookup already answered for this path. Its real path's entry goes with it.
48
+ if ((answers.get(key)?.seq ?? -1) > seq)
49
+ continue;
50
+ put(key, real, at, seq);
51
+ wanted.delete(key);
31
52
  // A real path is its own identity.
32
- if (real !== null)
33
- answers.set(real, { real, at });
53
+ if (real !== null && (answers.get(real)?.seq ?? -1) <= seq)
54
+ put(real, real, at, seq);
34
55
  }
35
56
  };
36
57
  const resolveNow = async (path) => {
37
- const looked = await lookIdentities([resolve(path)], look);
38
- if (!looked.ok)
58
+ const key = resolve(path);
59
+ const looked = await lookIdentities([key], look);
60
+ if (!looked.ok) {
61
+ suspect.add(key);
39
62
  return "no-answer";
40
- store(looked.value);
41
- return looked.value.get(resolve(path)) ?? null;
63
+ }
64
+ suspect.delete(key);
65
+ // No start known: older than every answer, so it only fills what nothing answered yet.
66
+ store(looked.value, looked.started ?? 0);
67
+ return answers.get(key)?.real ?? null;
68
+ };
69
+ const refreshNow = async (paths) => {
70
+ const at = now();
71
+ const due = new Set(wanted);
72
+ for (const one of paths) {
73
+ const key = resolve(one);
74
+ const answer = answers.get(key);
75
+ if (answer === undefined || at - answer.at >= STALE_MS)
76
+ due.add(key);
77
+ }
78
+ for (const [key, answer] of answers)
79
+ if (at - answer.at >= STALE_MS)
80
+ due.add(key);
81
+ if (due.size === 0)
82
+ return true;
83
+ const alone = [...due].filter(one => suspect.has(one));
84
+ const batched = [...due].filter(one => !suspect.has(one));
85
+ if (batched.length > 0) {
86
+ const batch = await lookIdentities(batched, look);
87
+ if (batch.ok)
88
+ store(batch.value, batch.started ?? 0);
89
+ else
90
+ alone.push(...batched);
91
+ }
92
+ // One folder that doesn't answer mustn't hold the others: each on its own, a few at a time (inside the looks'
93
+ // child cap, leaving room for a person's actions), each under its deadline, each stored as soon as it answers.
94
+ let missed = false;
95
+ const worker = async () => {
96
+ for (let one = alone.shift(); one !== undefined; one = alone.shift())
97
+ if (await resolveNow(one) === "no-answer")
98
+ missed = true;
99
+ };
100
+ await Promise.all(Array.from({ length: Math.min(IDENTITY_LOOKS, alone.length) }, worker));
101
+ return !missed;
42
102
  };
103
+ let passes = Promise.resolve();
43
104
  return {
44
105
  hot: path => {
45
106
  const key = resolve(path);
@@ -68,28 +129,13 @@ export function projectIdentities(look = {}, now = Date.now) {
68
129
  return undefined;
69
130
  },
70
131
  resolveNow,
71
- refresh: async (paths) => {
72
- const at = now();
73
- const due = new Set(wanted);
74
- for (const one of paths) {
75
- const key = resolve(one);
76
- const answer = answers.get(key);
77
- if (answer === undefined || at - answer.at >= STALE_MS)
78
- due.add(key);
79
- }
80
- for (const [key, answer] of answers)
81
- if (at - answer.at >= STALE_MS)
82
- due.add(key);
83
- if (due.size === 0)
84
- return true;
85
- const batch = await lookIdentities([...due], look);
86
- if (batch.ok) {
87
- store(batch.value);
88
- return true;
89
- }
90
- // One folder that doesn't answer mustn't hold the others: each on its own, in parallel (the looks are capped).
91
- const each = await Promise.all([...due].map(one => resolveNow(one)));
92
- return each.every(one => one !== "no-answer");
132
+ generation: () => generation,
133
+ refresh: paths => {
134
+ // One pass at a time: a pass asked for while another runs waits for it, then resolves only what is still due.
135
+ const asked = [...paths];
136
+ const pass = passes.then(() => refreshNow(asked));
137
+ passes = pass.then(() => undefined, () => undefined);
138
+ return pass;
93
139
  },
94
140
  };
95
141
  }
@@ -101,6 +147,7 @@ export function syncProjectIdentities() {
101
147
  strict: path => canonicalProject(path),
102
148
  vouched: path => canonicalProject(path) ?? undefined,
103
149
  resolveNow: async (path) => canonicalProject(path),
150
+ generation: () => 0,
104
151
  refresh: async () => true,
105
152
  };
106
153
  }
@@ -22,18 +22,19 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
22
22
  export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
- history?: never;
25
+ title?: never;
26
+ content?: never;
26
27
  revision: number;
27
28
  stale: boolean;
28
29
  notice: string;
29
30
  decision?: never;
30
- title?: never;
31
- content?: never;
31
+ history?: never;
32
32
  instructions?: never;
33
33
  references?: never;
34
34
  decisions?: never;
35
35
  } | {
36
- history?: never;
36
+ title?: never;
37
+ content?: never;
37
38
  stale?: never;
38
39
  decision: {
39
40
  repo: string;
@@ -50,28 +51,27 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
50
51
  id: number;
51
52
  };
52
53
  notice: string;
53
- title?: never;
54
- content?: never;
55
54
  revision?: never;
55
+ history?: never;
56
56
  instructions?: never;
57
57
  references?: never;
58
58
  decisions?: never;
59
59
  } | {
60
- history?: never;
61
60
  stale?: never;
62
61
  decision?: never;
63
62
  revision: number;
64
63
  title: string;
65
64
  content: string;
66
65
  notice: string;
66
+ history?: never;
67
67
  instructions?: never;
68
68
  references?: never;
69
69
  decisions?: never;
70
70
  } | {
71
- stale?: never;
72
- decision?: never;
73
71
  title?: never;
74
72
  content?: never;
73
+ stale?: never;
74
+ decision?: never;
75
75
  revision: number;
76
76
  history: {
77
77
  revision: number;
@@ -0,0 +1,119 @@
1
+ import type { Store } from './store.js';
2
+ import type { WorkSummaryAccess } from './work-summary.js';
3
+ export declare const ACTIVITY_DAYS = 60;
4
+ export declare const ACTIVITY_LIMIT = 500;
5
+ export type CompletionActor = 'human' | 'lead' | 'landed' | 'machine-or-unknown';
6
+ export type ActivityFamily = {
7
+ root: string;
8
+ task: string;
9
+ title: string;
10
+ run: number | null;
11
+ href: string;
12
+ state: 'ready' | 'completed' | 'history-unavailable';
13
+ kind: string;
14
+ finishedAt: string | null;
15
+ completedAt: string | null;
16
+ actor: CompletionActor | null;
17
+ checks: string;
18
+ revisions: number | null;
19
+ elapsedMs: number | null;
20
+ runs: number;
21
+ /** Runs whose amount is known (reported-usage.ts runAmountOf): API dollars or subscription usage. */
22
+ knownCostRuns: number;
23
+ subscriptionRuns: number;
24
+ /** API dollars only; subscription usage is never added to them. */
25
+ recordedUsd: number | null;
26
+ subscriptionReportedUsd: number | null;
27
+ /** Every attempt billed in known API dollars; otherwise the family's dollar total is unknown. */
28
+ totalUsd: number | null;
29
+ };
30
+ export type ProjectOutcomes = {
31
+ asOf: string;
32
+ since: string;
33
+ days: number;
34
+ limit: number;
35
+ truncated: boolean;
36
+ ready: {
37
+ count: number;
38
+ oldestFinishedAgeMs: number | null;
39
+ dated: number;
40
+ };
41
+ completed: {
42
+ count: number;
43
+ actors: Record<CompletionActor, number>;
44
+ scouts: number;
45
+ failedChecks: number;
46
+ };
47
+ finishedToDecision: {
48
+ meanMs: number | null;
49
+ samples: number;
50
+ };
51
+ revisions: {
52
+ count: number;
53
+ perCompletedFamily: number | null;
54
+ samples: number;
55
+ };
56
+ cost: {
57
+ recordedUsd: number | null;
58
+ recordedPerCompletedFamilyUsd: number | null;
59
+ runs: number;
60
+ knownRuns: number;
61
+ subscriptionRuns: number;
62
+ subscriptionReportedUsd: number | null;
63
+ fullyCostedFamilies: number;
64
+ families: number;
65
+ };
66
+ incompleteHistory: number;
67
+ families: ActivityFamily[];
68
+ };
69
+ /** Callers supply their current authenticated ceiling. A path alone grants no access. */
70
+ export declare function projectOutcomes(store: Store, project: string, now: Date, access: WorkSummaryAccess): ProjectOutcomes;
71
+ /** The lead's page of one report: every aggregate unchanged, family rows in pages bounded by count AND by their
72
+ * serialized size, so the reply always fits the tool-result cap (lead.ts, converse.ts LEAD_TOOL_RESULT_CAP_BYTES) and
73
+ * the lead reads further with nextOffset. Long ids and titles are shortened; an over-long link is left out. */
74
+ export declare const ACTIVITY_LEAD_PAGE = 10;
75
+ export declare const ACTIVITY_LEAD_PAGE_BYTES = 12000;
76
+ export declare function projectOutcomesPage(report: ProjectOutcomes, offset?: number): {
77
+ asOf: string;
78
+ since: string;
79
+ days: number;
80
+ limit: number;
81
+ truncated: boolean;
82
+ ready: {
83
+ count: number;
84
+ oldestFinishedAgeMs: number | null;
85
+ dated: number;
86
+ };
87
+ completed: {
88
+ count: number;
89
+ actors: Record<CompletionActor, number>;
90
+ scouts: number;
91
+ failedChecks: number;
92
+ };
93
+ finishedToDecision: {
94
+ meanMs: number | null;
95
+ samples: number;
96
+ };
97
+ revisions: {
98
+ count: number;
99
+ perCompletedFamily: number | null;
100
+ samples: number;
101
+ };
102
+ cost: {
103
+ recordedUsd: number | null;
104
+ recordedPerCompletedFamilyUsd: number | null;
105
+ runs: number;
106
+ knownRuns: number;
107
+ subscriptionRuns: number;
108
+ subscriptionReportedUsd: number | null;
109
+ fullyCostedFamilies: number;
110
+ families: number;
111
+ };
112
+ incompleteHistory: number;
113
+ families: (Omit<ActivityFamily, "href"> & {
114
+ href: string | null;
115
+ })[];
116
+ familiesTotal: number;
117
+ familiesOffset: number;
118
+ nextOffset: number | null;
119
+ };
@@ -0,0 +1,79 @@
1
+ /** Read-only Activity. A completion is a saved decision, never a claim of
2
+ * passing checks, deployment, review labor, or improved productivity. */
3
+ import { chatResultHref } from './chat-controls.js';
4
+ import { workOutcomeRows } from './work-index.js';
5
+ import { COMPLETION_BY_PERSON, LANDED_ACTOR } from './result-completion.js';
6
+ export const ACTIVITY_DAYS = 60;
7
+ export const ACTIVITY_LIMIT = 500;
8
+ const text = (value) => value == null ? null : String(value);
9
+ const duration = (from, to) => {
10
+ const delta = from === null ? NaN : Date.parse(to) - Date.parse(from);
11
+ return Number.isFinite(delta) && delta >= 0 ? delta : null;
12
+ };
13
+ const actorOf = (actor, detail, byLead) => actor === LANDED_ACTOR ? 'landed'
14
+ : byLead || actor?.startsWith('lead:') ? 'lead'
15
+ : actor?.startsWith('operator:') && detail === COMPLETION_BY_PERSON ? 'human' : 'machine-or-unknown';
16
+ /** Callers supply their current authenticated ceiling. A path alone grants no access. */
17
+ export function projectOutcomes(store, project, now, access) {
18
+ if (access.repos !== null && !access.repos.includes(project))
19
+ throw new Error('Project activity is outside your access.');
20
+ const asOf = now.toISOString(), since = new Date(now.getTime() - ACTIVITY_DAYS * 86400000).toISOString();
21
+ const rows = workOutcomeRows(store, now, access, project, since, ACTIVITY_LIMIT);
22
+ const families = rows.slice(0, ACTIVITY_LIMIT).map(row => {
23
+ const state = row['code'] === 'complete' ? 'completed' : row['code'] === 'ready-to-check' ? 'ready' : 'history-unavailable';
24
+ const finishedAt = text(row['finished_at']), completedAt = text(row['checked_at']);
25
+ const runs = Number(row['runs']), apiRuns = Number(row['api_runs']), subscriptionRuns = Number(row['subscription_runs']);
26
+ const recordedUsd = apiRuns > 0 && Number.isFinite(Number(row['api_microusd'])) ? Number(row['api_microusd']) / 1_000_000 : null;
27
+ const subscriptionReportedUsd = row['subscription_usd'] != null && Number.isFinite(Number(row['subscription_usd'])) ? Number(row['subscription_usd']) : null;
28
+ const knownCostRuns = apiRuns + subscriptionRuns;
29
+ const task = String(row['task_id']);
30
+ return { root: String(row['root_id']), task, title: String(row['title']), run: row['result_id'] == null ? null : Number(row['result_id']),
31
+ href: row['result_id'] == null ? `/t/${encodeURIComponent(task)}` : chatResultHref(task, Number(row['result_id'])), state, kind: text(row['result_role']) ?? 'unknown',
32
+ finishedAt, completedAt, actor: state === 'completed' ? actorOf(text(row['checked_actor']), text(row['checked_detail']), Number(row['completed_by_lead']) === 1) : null,
33
+ checks: text(row['check_status']) ?? 'not-recorded', revisions: state === 'history-unavailable' ? null : Number(row['version_count']) - 1,
34
+ elapsedMs: duration(finishedAt, completedAt ?? asOf), runs, knownCostRuns, subscriptionRuns, recordedUsd, subscriptionReportedUsd,
35
+ totalUsd: runs > 0 && runs === apiRuns ? recordedUsd : null };
36
+ });
37
+ const ready = families.filter(one => one.state === 'ready'), completed = families.filter(one => one.state === 'completed');
38
+ const times = completed.flatMap(one => one.elapsedMs === null ? [] : [one.elapsedMs]);
39
+ const ages = ready.flatMap(one => one.elapsedMs === null ? [] : [one.elapsedMs]);
40
+ const actors = { human: 0, lead: 0, landed: 0, 'machine-or-unknown': 0 };
41
+ for (const one of completed)
42
+ actors[one.actor]++;
43
+ const knownRuns = completed.reduce((sum, one) => sum + one.knownCostRuns, 0);
44
+ const recordedUsd = completed.some(one => one.recordedUsd !== null) ? completed.reduce((sum, one) => sum + (one.recordedUsd ?? 0), 0) : null;
45
+ const subscriptionRuns = completed.reduce((sum, one) => sum + one.subscriptionRuns, 0);
46
+ const subscriptionReportedUsd = completed.some(one => one.subscriptionReportedUsd !== null) ? completed.reduce((sum, one) => sum + (one.subscriptionReportedUsd ?? 0), 0) : null;
47
+ const revisions = completed.reduce((sum, one) => sum + (one.revisions ?? 0), 0);
48
+ return { asOf, since, days: ACTIVITY_DAYS, limit: ACTIVITY_LIMIT, truncated: rows.length > ACTIVITY_LIMIT,
49
+ ready: { count: ready.length, oldestFinishedAgeMs: ages.length ? Math.max(...ages) : null, dated: ages.length },
50
+ completed: { count: completed.length, actors, scouts: completed.filter(one => one.kind === 'scout').length, failedChecks: completed.filter(one => one.checks === 'failed').length },
51
+ finishedToDecision: { meanMs: times.length ? times.reduce((a, b) => a + b, 0) / times.length : null, samples: times.length },
52
+ revisions: { count: revisions, perCompletedFamily: completed.length ? revisions / completed.length : null, samples: completed.length },
53
+ cost: { recordedUsd, recordedPerCompletedFamilyUsd: completed.length && recordedUsd !== null ? recordedUsd / completed.length : null,
54
+ runs: completed.reduce((sum, one) => sum + one.runs, 0), knownRuns, subscriptionRuns, subscriptionReportedUsd, fullyCostedFamilies: completed.filter(one => one.totalUsd !== null).length, families: completed.length },
55
+ incompleteHistory: families.filter(one => one.state === 'history-unavailable').length, families };
56
+ }
57
+ /** The lead's page of one report: every aggregate unchanged, family rows in pages bounded by count AND by their
58
+ * serialized size, so the reply always fits the tool-result cap (lead.ts, converse.ts LEAD_TOOL_RESULT_CAP_BYTES) and
59
+ * the lead reads further with nextOffset. Long ids and titles are shortened; an over-long link is left out. */
60
+ export const ACTIVITY_LEAD_PAGE = 10;
61
+ export const ACTIVITY_LEAD_PAGE_BYTES = 12_000;
62
+ const shorten = (value, max) => [...value].length > max ? `${[...value].slice(0, max - 1).join('')}…` : value;
63
+ const embeddedBytes = (value) => Buffer.byteLength(JSON.stringify(JSON.stringify(value)), 'utf8');
64
+ export function projectOutcomesPage(report, offset = 0) {
65
+ const start = Math.max(0, Math.min(Number.isSafeInteger(offset) ? offset : 0, report.families.length));
66
+ const page = [];
67
+ const base = { ...report, families: page, familiesTotal: report.families.length, familiesOffset: start, nextOffset: null };
68
+ for (const one of report.families.slice(start, start + ACTIVITY_LEAD_PAGE)) {
69
+ const row = { ...one, root: shorten(one.root, 200), task: shorten(one.task, 200), title: shorten(one.title, 80), href: Buffer.byteLength(one.href) > 512 ? null : one.href };
70
+ page.push(row);
71
+ if (page.length > 1 && embeddedBytes(base) > ACTIVITY_LEAD_PAGE_BYTES) {
72
+ page.pop();
73
+ break;
74
+ }
75
+ }
76
+ const next = start + page.length;
77
+ base.nextOffset = next < report.families.length ? next : null;
78
+ return base;
79
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Store } from "./store.js";
2
+ import { type FeedbackImage } from "./result-review.js";
2
3
  export type RevisionResult = {
3
4
  ok: true;
4
5
  id: string;
@@ -8,6 +9,8 @@ export type RevisionResult = {
8
9
  message: string;
9
10
  };
10
11
  type RevisionInput = Parameters<typeof createResultRevision>[2];
12
+ /** Re-prove image custody and current result at each write. No file paths supplied by callers. */
13
+ export declare function feedbackImageProblem(store: Store, root: string, runId: number, image: FeedbackImage | null | undefined): string | null;
11
14
  /** One user action: add the typed change and seal exactly the displayed
12
15
  * saved notes. A refusal rolls back the note; a replay returns the same task. */
13
16
  export declare function requestResultChanges(store: Store, evidenceRoot: string, input: RevisionInput & {
@@ -15,6 +18,7 @@ export declare function requestResultChanges(store: Store, evidenceRoot: string,
15
18
  path: string;
16
19
  line: string;
17
20
  request: string | null;
21
+ image?: FeedbackImage | null;
18
22
  }, now: Date): RevisionResult;
19
23
  export declare function createResultRevision(store: Store, evidenceRoot: string, input: {
20
24
  run: number;