@oss-autopilot/core 3.1.0 → 3.3.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 (66) hide show
  1. package/dist/cli-registry.js +113 -3
  2. package/dist/cli.bundle.cjs +96 -92
  3. package/dist/commands/check-integration.js +8 -8
  4. package/dist/commands/comments.js +3 -0
  5. package/dist/commands/config.js +14 -7
  6. package/dist/commands/daily-render.js +10 -5
  7. package/dist/commands/daily.js +6 -1
  8. package/dist/commands/dashboard-lifecycle.js +1 -1
  9. package/dist/commands/dashboard-process.js +4 -4
  10. package/dist/commands/dashboard-server.js +7 -6
  11. package/dist/commands/dashboard.js +2 -2
  12. package/dist/commands/detect-formatters.js +3 -3
  13. package/dist/commands/doctor.js +5 -5
  14. package/dist/commands/guidelines.d.ts +67 -0
  15. package/dist/commands/guidelines.js +159 -0
  16. package/dist/commands/index.d.ts +9 -0
  17. package/dist/commands/index.js +9 -0
  18. package/dist/commands/list-move-tier.js +5 -5
  19. package/dist/commands/local-repos.js +9 -9
  20. package/dist/commands/parse-list.js +10 -10
  21. package/dist/commands/scout-bridge.js +2 -2
  22. package/dist/commands/setup.js +24 -13
  23. package/dist/commands/skip-add.js +6 -3
  24. package/dist/commands/skip-file-parser.js +3 -3
  25. package/dist/commands/startup.js +11 -8
  26. package/dist/commands/state-cmd.js +1 -1
  27. package/dist/commands/status.js +7 -0
  28. package/dist/commands/validation.js +3 -3
  29. package/dist/commands/vet-list.js +12 -8
  30. package/dist/commands/vet.js +1 -2
  31. package/dist/core/__fixtures__/prompt-injection-payloads.d.ts +22 -0
  32. package/dist/core/__fixtures__/prompt-injection-payloads.js +109 -0
  33. package/dist/core/anti-llm-policy.js +5 -5
  34. package/dist/core/auth.js +5 -5
  35. package/dist/core/daily-logic.js +8 -4
  36. package/dist/core/dates.js +3 -3
  37. package/dist/core/errors.d.ts +29 -0
  38. package/dist/core/errors.js +63 -0
  39. package/dist/core/formatter-detection.js +9 -9
  40. package/dist/core/gist-state-store.d.ts +19 -3
  41. package/dist/core/gist-state-store.js +81 -15
  42. package/dist/core/guidelines-store.d.ts +74 -0
  43. package/dist/core/guidelines-store.js +130 -0
  44. package/dist/core/http-cache.js +6 -6
  45. package/dist/core/index.d.ts +2 -0
  46. package/dist/core/index.js +2 -0
  47. package/dist/core/issue-conversation.js +3 -1
  48. package/dist/core/paths.js +4 -4
  49. package/dist/core/pr-comments-fetcher.d.ts +67 -0
  50. package/dist/core/pr-comments-fetcher.js +125 -0
  51. package/dist/core/pr-monitor.js +1 -2
  52. package/dist/core/pr-template.js +1 -1
  53. package/dist/core/state-persistence.d.ts +6 -0
  54. package/dist/core/state-persistence.js +27 -9
  55. package/dist/core/state-schema.d.ts +5 -1
  56. package/dist/core/state-schema.js +7 -1
  57. package/dist/core/state.d.ts +60 -0
  58. package/dist/core/state.js +136 -13
  59. package/dist/core/types.d.ts +1 -1
  60. package/dist/core/types.js +2 -2
  61. package/dist/core/untrusted-content.d.ts +48 -0
  62. package/dist/core/untrusted-content.js +106 -0
  63. package/dist/core/urls.js +2 -2
  64. package/dist/formatters/json.d.ts +53 -3
  65. package/dist/formatters/json.js +49 -14
  66. package/package.json +1 -1
@@ -26,6 +26,24 @@ export declare function maybeCheckpoint(stateManager: StateManager, callerModule
26
26
  * Retains lightweight CRUD operations for config, issues, shelving, dismissal,
27
27
  * and status overrides.
28
28
  */
29
+ /**
30
+ * Surfaced when the in-memory cached state is no longer in sync with the
31
+ * canonical Gist — typically because `refreshFromGist()` failed (network
32
+ * blip, rate limit, expired token) or because the bootstrap fell back to
33
+ * the local cache file (#1193). Commands include this in their `--json`
34
+ * envelope so cron/dashboard consumers can react instead of silently
35
+ * operating on stale data.
36
+ */
37
+ export interface StalenessInfo {
38
+ /** Why we're operating on cached data. Forward-compatible with future sources. */
39
+ source: 'cache';
40
+ /** Human-readable reason from the underlying error. */
41
+ reason: string;
42
+ /** ISO timestamp of the most recent successful refresh, or null if never. */
43
+ lastSuccessfulRefresh: string | null;
44
+ /** ISO timestamp when this staleness marker was first set. */
45
+ detectedAt: string;
46
+ }
29
47
  export declare class StateManager {
30
48
  protected state: AgentState;
31
49
  protected inMemoryOnly: boolean;
@@ -34,6 +52,8 @@ export declare class StateManager {
34
52
  private _batchDirty;
35
53
  protected gistStore: GistStateStore | null;
36
54
  protected gistDegraded: boolean;
55
+ private staleness;
56
+ private lastSuccessfulRefreshAt;
37
57
  /**
38
58
  * Create a new StateManager instance.
39
59
  * @param inMemoryOnly - When true, state is held only in memory and never read from or
@@ -105,6 +125,29 @@ export declare class StateManager {
105
125
  isGistMode(): boolean;
106
126
  /** Whether the Gist is in degraded mode (using local cache fallback). */
107
127
  isGistDegraded(): boolean;
128
+ /**
129
+ * Whether per-repo guidelines (#867) are available. True iff the Gist store
130
+ * is initialized — in local-only mode, guidelines are unavailable and
131
+ * write operations would throw {@link GuidelinesNotAvailableError}.
132
+ */
133
+ isGuidelinesAvailable(): boolean;
134
+ /**
135
+ * Read the per-repo guidelines for `repo` (#867). Returns null when in
136
+ * local mode, when no file exists, or when the file is empty (tombstoned).
137
+ */
138
+ getGuidelines(repo: string): string | null;
139
+ /**
140
+ * Persist per-repo guidelines for `repo`. Throws when not in Gist mode or
141
+ * when content exceeds the byte budget.
142
+ */
143
+ setGuidelines(repo: string, content: string): void;
144
+ /**
145
+ * Tombstone the guidelines file for `repo` so subsequent reads return null.
146
+ * Throws when not in Gist mode.
147
+ */
148
+ deleteGuidelines(repo: string): void;
149
+ /** List repos with non-empty guidelines stored in the Gist. */
150
+ listGuidelinesRepos(): string[];
108
151
  /**
109
152
  * Get the current state as a read-only snapshot.
110
153
  */
@@ -119,6 +162,13 @@ export declare class StateManager {
119
162
  * Throttled to once per 30 seconds by GistStateStore. Returns true if state was refreshed.
120
163
  */
121
164
  refreshFromGist(): Promise<boolean>;
165
+ /**
166
+ * Returns a staleness marker when the in-memory state diverged from the
167
+ * canonical Gist (refresh failure or degraded bootstrap), or `null` when
168
+ * state is current. Commands surface this via their `--json` warnings
169
+ * envelope (#1193).
170
+ */
171
+ getStateStaleness(): StalenessInfo | null;
122
172
  /**
123
173
  * Store the latest daily digest and update the digest timestamp.
124
174
  * @param digest - The daily digest to store
@@ -176,6 +226,16 @@ export declare class StateManager {
176
226
  };
177
227
  /** Returns the most recent close date, used as a watermark for incremental fetching. */
178
228
  getClosedPRWatermark(): string | undefined;
229
+ /**
230
+ * Stamp `commentsFetchedAt` on the merged or closed PR matching `url` (#867).
231
+ * No-op when no PR with that URL is stored.
232
+ */
233
+ markPRCommentsFetched(url: string, fetchedAt: string): void;
234
+ /**
235
+ * Stamp `learningsExtractedAt` on the merged or closed PR matching `url` (#867).
236
+ * No-op when no PR with that URL is stored.
237
+ */
238
+ markPRLearningsExtracted(url: string, extractedAt: string): void;
179
239
  /**
180
240
  * Merge partial config updates into the current configuration.
181
241
  * @param config - Partial config object to merge
@@ -3,13 +3,14 @@
3
3
  * Thin coordinator that delegates persistence to state-persistence.ts
4
4
  * and scoring logic to repo-score-manager.ts.
5
5
  */
6
- import * as fs from 'fs';
6
+ import * as fs from 'node:fs';
7
7
  import { AgentStateSchema } from './state-schema.js';
8
8
  import { loadState, saveState, reloadStateIfChanged, createFreshState, atomicWriteFileSync, } from './state-persistence.js';
9
9
  import * as repoScoring from './repo-score-manager.js';
10
10
  import { debug, warn } from './logger.js';
11
- import { errorMessage, ConfigurationError, ConcurrencyError } from './errors.js';
11
+ import { errorMessage, ConfigurationError, ConcurrencyError, isTransientNetworkError } from './errors.js';
12
12
  import { GistStateStore } from './gist-state-store.js';
13
+ import * as guidelinesStoreModule from './guidelines-store.js';
13
14
  import { getStatePath, getStateCachePath } from './paths.js';
14
15
  import { parseGitHubUrl } from './urls.js';
15
16
  export { acquireLock, releaseLock, atomicWriteFileSync } from './state-persistence.js';
@@ -52,13 +53,6 @@ export async function maybeCheckpoint(stateManager, callerModule) {
52
53
  warn(callerModule, `Gist checkpoint failed (local mutation succeeded, will retry on next push): ${errorMessage(err)}`);
53
54
  }
54
55
  }
55
- /**
56
- * Singleton manager for persistent agent state stored in ~/.oss-autopilot/state.json.
57
- *
58
- * Delegates file I/O to state-persistence.ts and scoring logic to repo-score-manager.ts.
59
- * Retains lightweight CRUD operations for config, issues, shelving, dismissal,
60
- * and status overrides.
61
- */
62
56
  export class StateManager {
63
57
  state;
64
58
  inMemoryOnly;
@@ -67,6 +61,8 @@ export class StateManager {
67
61
  _batchDirty = false;
68
62
  gistStore = null;
69
63
  gistDegraded = false;
64
+ staleness = null;
65
+ lastSuccessfulRefreshAt = null;
70
66
  /**
71
67
  * Create a new StateManager instance.
72
68
  * @param inMemoryOnly - When true, state is held only in memory and never read from or
@@ -130,6 +126,16 @@ export class StateManager {
130
126
  manager.gistStore = gistStore;
131
127
  manager.gistDegraded = result.degraded ?? false;
132
128
  manager.inMemoryOnly = false; // re-enable persistence
129
+ // Seed the staleness marker if bootstrap fell back to the local cache —
130
+ // a `daily` running on a cron right after this start needs to know.
131
+ if (result.degraded) {
132
+ manager.staleness = {
133
+ source: 'cache',
134
+ reason: 'initial Gist bootstrap fell back to local cache',
135
+ lastSuccessfulRefresh: null,
136
+ detectedAt: new Date().toISOString(),
137
+ };
138
+ }
133
139
  return manager;
134
140
  }
135
141
  /**
@@ -274,6 +280,41 @@ export class StateManager {
274
280
  isGistDegraded() {
275
281
  return this.gistDegraded;
276
282
  }
283
+ /**
284
+ * Whether per-repo guidelines (#867) are available. True iff the Gist store
285
+ * is initialized — in local-only mode, guidelines are unavailable and
286
+ * write operations would throw {@link GuidelinesNotAvailableError}.
287
+ */
288
+ isGuidelinesAvailable() {
289
+ return this.gistStore !== null;
290
+ }
291
+ /**
292
+ * Read the per-repo guidelines for `repo` (#867). Returns null when in
293
+ * local mode, when no file exists, or when the file is empty (tombstoned).
294
+ */
295
+ getGuidelines(repo) {
296
+ return guidelinesStoreModule.getGuidelines(this.gistStore, repo);
297
+ }
298
+ /**
299
+ * Persist per-repo guidelines for `repo`. Throws when not in Gist mode or
300
+ * when content exceeds the byte budget.
301
+ */
302
+ setGuidelines(repo, content) {
303
+ guidelinesStoreModule.setGuidelines(this.gistStore, repo, content);
304
+ this.autoSave();
305
+ }
306
+ /**
307
+ * Tombstone the guidelines file for `repo` so subsequent reads return null.
308
+ * Throws when not in Gist mode.
309
+ */
310
+ deleteGuidelines(repo) {
311
+ guidelinesStoreModule.deleteGuidelines(this.gistStore, repo);
312
+ this.autoSave();
313
+ }
314
+ /** List repos with non-empty guidelines stored in the Gist. */
315
+ listGuidelinesRepos() {
316
+ return guidelinesStoreModule.listGuidelinesRepos(this.gistStore);
317
+ }
277
318
  /**
278
319
  * Get the current state as a read-only snapshot.
279
320
  */
@@ -309,6 +350,15 @@ export class StateManager {
309
350
  const raw = this.gistStore.cachedFiles.get('state.json');
310
351
  if (!raw) {
311
352
  warn(MODULE, 'Gist refreshed but state.json missing from cache');
353
+ // HTTP fetch succeeded but the Gist body is missing state.json — we
354
+ // still have stale in-memory data, so surface a marker rather than
355
+ // silently returning false (#1193 review).
356
+ this.staleness = {
357
+ source: 'cache',
358
+ reason: 'Gist refresh returned no state.json file',
359
+ lastSuccessfulRefresh: this.lastSuccessfulRefreshAt,
360
+ detectedAt: new Date().toISOString(),
361
+ };
312
362
  return false;
313
363
  }
314
364
  try {
@@ -317,11 +367,41 @@ export class StateManager {
317
367
  }
318
368
  catch (err) {
319
369
  warn(MODULE, `Failed to parse refreshed Gist state: ${errorMessage(err)}`);
370
+ // Same reasoning as the missing-file branch: parse failure leaves us
371
+ // on stale in-memory state, so flag it.
372
+ this.staleness = {
373
+ source: 'cache',
374
+ reason: `Gist refresh succeeded but payload was invalid: ${errorMessage(err)}`,
375
+ lastSuccessfulRefresh: this.lastSuccessfulRefreshAt,
376
+ detectedAt: new Date().toISOString(),
377
+ };
320
378
  return false;
321
379
  }
380
+ // Successful refresh clears any prior staleness (#1193).
381
+ this.lastSuccessfulRefreshAt = new Date().toISOString();
382
+ this.staleness = null;
383
+ }
384
+ else if (this.gistStore.lastRefreshError) {
385
+ // Distinguish "fetch failed" (set marker) from "throttled" (preserve
386
+ // any existing marker, set nothing new).
387
+ this.staleness = {
388
+ source: 'cache',
389
+ reason: errorMessage(this.gistStore.lastRefreshError),
390
+ lastSuccessfulRefresh: this.lastSuccessfulRefreshAt,
391
+ detectedAt: new Date().toISOString(),
392
+ };
322
393
  }
323
394
  return refreshed;
324
395
  }
396
+ /**
397
+ * Returns a staleness marker when the in-memory state diverged from the
398
+ * canonical Gist (refresh failure or degraded bootstrap), or `null` when
399
+ * state is current. Commands surface this via their `--json` warnings
400
+ * envelope (#1193).
401
+ */
402
+ getStateStaleness() {
403
+ return this.staleness;
404
+ }
325
405
  // === Dashboard Data Setters ===
326
406
  /**
327
407
  * Store the latest daily digest and update the digest timestamp.
@@ -434,6 +514,40 @@ export class StateManager {
434
514
  getClosedPRWatermark() {
435
515
  return this.state.closedPRs?.[0]?.closedAt || undefined;
436
516
  }
517
+ /**
518
+ * Stamp `commentsFetchedAt` on the merged or closed PR matching `url` (#867).
519
+ * No-op when no PR with that URL is stored.
520
+ */
521
+ markPRCommentsFetched(url, fetchedAt) {
522
+ const merged = this.state.mergedPRs?.find((pr) => pr.url === url);
523
+ if (merged) {
524
+ merged.commentsFetchedAt = fetchedAt;
525
+ this.autoSave();
526
+ return;
527
+ }
528
+ const closed = this.state.closedPRs?.find((pr) => pr.url === url);
529
+ if (closed) {
530
+ closed.commentsFetchedAt = fetchedAt;
531
+ this.autoSave();
532
+ }
533
+ }
534
+ /**
535
+ * Stamp `learningsExtractedAt` on the merged or closed PR matching `url` (#867).
536
+ * No-op when no PR with that URL is stored.
537
+ */
538
+ markPRLearningsExtracted(url, extractedAt) {
539
+ const merged = this.state.mergedPRs?.find((pr) => pr.url === url);
540
+ if (merged) {
541
+ merged.learningsExtractedAt = extractedAt;
542
+ this.autoSave();
543
+ return;
544
+ }
545
+ const closed = this.state.closedPRs?.find((pr) => pr.url === url);
546
+ if (closed) {
547
+ closed.learningsExtractedAt = extractedAt;
548
+ this.autoSave();
549
+ }
550
+ }
437
551
  // === Configuration ===
438
552
  /**
439
553
  * Merge partial config updates into the current configuration.
@@ -777,11 +891,20 @@ export async function getStateManagerAsync(token) {
777
891
  })
778
892
  .catch((err) => {
779
893
  asyncManagerPromise = null;
780
- // Configuration errors (e.g. GistPermissionError) must surface to the user
894
+ // Configuration errors (e.g. GistPermissionError, GistCorruptError)
895
+ // must surface — falling back to local-only would silently split state
896
+ // across machines (#1202).
781
897
  if (err instanceof ConfigurationError)
782
898
  throw err;
783
- warn(MODULE, `Gist initialization failed, falling back to local-only mode: ${err}`);
784
- return getStateManager(); // fall back to sync/local for transient errors
899
+ // Only fall back on actual network/server errors. Other failures
900
+ // (auth, schema, concurrency conflicts) indicate the Gist mode is
901
+ // broken in a way the user needs to address — silently falling back
902
+ // would write subsequent mutations to the local file while the Gist
903
+ // marker stays in config, causing permanent cross-machine divergence.
904
+ if (!isTransientNetworkError(err))
905
+ throw err;
906
+ warn(MODULE, `Gist initialization failed (transient network error), falling back to local-only mode: ${err}`);
907
+ return getStateManager();
785
908
  });
786
909
  return asyncManagerPromise;
787
910
  }
@@ -810,7 +933,7 @@ export async function ensureGistPersistence(token) {
810
933
  return;
811
934
  let persistence;
812
935
  try {
813
- const raw = fs.readFileSync(getStatePath(), 'utf-8');
936
+ const raw = fs.readFileSync(getStatePath(), 'utf8');
814
937
  persistence = JSON.parse(raw)?.config?.persistence;
815
938
  }
816
939
  catch {
@@ -233,7 +233,7 @@ interface CommentedIssueWithoutResponse extends CommentedIssueBase {
233
233
  export type CommentedIssue = CommentedIssueWithResponse | CommentedIssueWithoutResponse;
234
234
  /** Default configuration applied to new state files. All fields can be overridden via `/setup-oss`. */
235
235
  export declare const DEFAULT_CONFIG: AgentConfig;
236
- /** Initial state written to `~/.oss-autopilot/state.json` on first run. Uses v3 architecture. */
236
+ /** Initial state written to `~/.oss-autopilot/state.json` on first run. Uses v4 architecture. */
237
237
  export declare const INITIAL_STATE: AgentState;
238
238
  export declare const PROJECT_CATEGORIES: ("nonprofit" | "devtools" | "infrastructure" | "web-frameworks" | "data-ml" | "education")[];
239
239
  export declare const ISSUE_SCOPES: ("advanced" | "beginner" | "intermediate")[];
@@ -12,8 +12,8 @@ export function isBelowMinStars(stargazersCount, minStars) {
12
12
  // ── Schema-derived constants ─────────────────────────────────────────
13
13
  /** Default configuration applied to new state files. All fields can be overridden via `/setup-oss`. */
14
14
  export const DEFAULT_CONFIG = AgentConfigSchema.parse({});
15
- /** Initial state written to `~/.oss-autopilot/state.json` on first run. Uses v3 architecture. */
16
- export const INITIAL_STATE = AgentStateSchema.parse({ version: 3 });
15
+ /** Initial state written to `~/.oss-autopilot/state.json` on first run. Uses v4 architecture. */
16
+ export const INITIAL_STATE = AgentStateSchema.parse({ version: 4 });
17
17
  // ── Const arrays (derived from Zod schemas for runtime iteration) ────
18
18
  export const PROJECT_CATEGORIES = ProjectCategorySchema.options;
19
19
  export const ISSUE_SCOPES = IssueScopeSchema.options;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Wrap GitHub-sourced text (PR titles, PR bodies, issue bodies, review
3
+ * comments) in a fenced delimiter so the LLM treats it as data, not
4
+ * instructions (#1192).
5
+ *
6
+ * The contract this module pins, exercised by `untrusted-content.test.ts`
7
+ * and the prompt-injection corpus:
8
+ *
9
+ * 1. Output starts with the open tag and ends with the close tag.
10
+ * 2. The close-tag literal NEVER appears inside the wrapped body — any
11
+ * occurrence in the input is escaped to a sentinel so an attacker
12
+ * cannot close the fence early and inject instructions after it.
13
+ * 3. `extractFromFence(wrapUntrustedContent(x, label))` returns `x`
14
+ * unchanged for any input — the wrapping is lossless.
15
+ *
16
+ * Consumers should pair this with the agent-side guidance in
17
+ * `workflows/reference.md` ("Prompt Injection Awareness"), which tells
18
+ * the LLM to ignore instructions inside `<github-content>` blocks.
19
+ *
20
+ * Non-goals:
21
+ * - This is NOT a content filter. We do not detect or strip prompt-
22
+ * injection payloads; the human-in-the-loop gate on `post`/`claim`
23
+ * remains the primary control.
24
+ */
25
+ export declare const UNTRUSTED_OPEN_TAG_NAME = "github-content";
26
+ export declare const UNTRUSTED_CLOSE_TAG = "</github-content>";
27
+ export interface UntrustedContentMeta {
28
+ /** GitHub login of the content's author (e.g. PR comment author). */
29
+ author?: string;
30
+ /** GitHub author_association (OWNER, MEMBER, CONTRIBUTOR, NONE, ...). */
31
+ association?: string;
32
+ /** Free-form provenance label (e.g. "pr-body", "review-comment"). */
33
+ source?: string;
34
+ }
35
+ /**
36
+ * Wrap `text` in a `<github-content>` fence labeled with `label` and the
37
+ * optional metadata. Any occurrence of the close-tag literal inside `text`
38
+ * is replaced with a zero-width-joined sentinel that round-trips losslessly
39
+ * via {@link extractFromFence}.
40
+ */
41
+ export declare function wrapUntrustedContent(text: string, label: string, meta?: UntrustedContentMeta): string;
42
+ /**
43
+ * Reverse of {@link wrapUntrustedContent}. Extracts the original body text
44
+ * (un-escaping any sentinel-encoded close tags). Throws if the input is not
45
+ * a single well-formed fence — callers shouldn't be parsing arbitrary
46
+ * markdown with this; it's for tests + symmetric reasoning only.
47
+ */
48
+ export declare function extractFromFence(fenced: string): string;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Wrap GitHub-sourced text (PR titles, PR bodies, issue bodies, review
3
+ * comments) in a fenced delimiter so the LLM treats it as data, not
4
+ * instructions (#1192).
5
+ *
6
+ * The contract this module pins, exercised by `untrusted-content.test.ts`
7
+ * and the prompt-injection corpus:
8
+ *
9
+ * 1. Output starts with the open tag and ends with the close tag.
10
+ * 2. The close-tag literal NEVER appears inside the wrapped body — any
11
+ * occurrence in the input is escaped to a sentinel so an attacker
12
+ * cannot close the fence early and inject instructions after it.
13
+ * 3. `extractFromFence(wrapUntrustedContent(x, label))` returns `x`
14
+ * unchanged for any input — the wrapping is lossless.
15
+ *
16
+ * Consumers should pair this with the agent-side guidance in
17
+ * `workflows/reference.md` ("Prompt Injection Awareness"), which tells
18
+ * the LLM to ignore instructions inside `<github-content>` blocks.
19
+ *
20
+ * Non-goals:
21
+ * - This is NOT a content filter. We do not detect or strip prompt-
22
+ * injection payloads; the human-in-the-loop gate on `post`/`claim`
23
+ * remains the primary control.
24
+ */
25
+ export const UNTRUSTED_OPEN_TAG_NAME = 'github-content';
26
+ export const UNTRUSTED_CLOSE_TAG = `</${UNTRUSTED_OPEN_TAG_NAME}>`;
27
+ /**
28
+ * Sentinels used to neutralize any literal open- or close-tag substring
29
+ * inside the wrapped body. We use HTML entity escapes (`&lt;` / `&gt;`) so
30
+ * the result is pure ASCII, lints cleanly, and round-trips losslessly. The
31
+ * `&` itself is escaped first so an input containing the literal text
32
+ * `&lt;/github-content&gt;` survives the wrap/unwrap round-trip without
33
+ * collision.
34
+ */
35
+ const AMP_ESCAPE = '&amp;';
36
+ const CLOSE_TAG_ESCAPE = `&lt;/${UNTRUSTED_OPEN_TAG_NAME}&gt;`;
37
+ const OPEN_TAG_PATTERN = new RegExp(`<${UNTRUSTED_OPEN_TAG_NAME}\\b`, 'g');
38
+ const OPEN_TAG_ESCAPE = `&lt;${UNTRUSTED_OPEN_TAG_NAME}`;
39
+ function escapeAttr(value) {
40
+ // Newlines/carriage returns can't break out of a quoted attribute (no `"`)
41
+ // but they visually fragment the open tag in the prompt text the LLM
42
+ // reads, so encode them as numeric entities for defense-in-depth.
43
+ return value
44
+ .replace(/&/g, '&amp;')
45
+ .replace(/"/g, '&quot;')
46
+ .replace(/</g, '&lt;')
47
+ .replace(/>/g, '&gt;')
48
+ .replace(/\n/g, '&#10;')
49
+ .replace(/\r/g, '&#13;');
50
+ }
51
+ /**
52
+ * Wrap `text` in a `<github-content>` fence labeled with `label` and the
53
+ * optional metadata. Any occurrence of the close-tag literal inside `text`
54
+ * is replaced with a zero-width-joined sentinel that round-trips losslessly
55
+ * via {@link extractFromFence}.
56
+ */
57
+ export function wrapUntrustedContent(text, label, meta = {}) {
58
+ // Escape order matters for round-trip correctness:
59
+ // 1. `&` first — so any pre-existing entity in the input gets a literal
60
+ // `&amp;` that survives the unwrap pass below.
61
+ // 2. Close-tag literals — replaced with the entity-escaped form.
62
+ // 3. Open-tag literals (matched only at boundaries via OPEN_TAG_PATTERN
63
+ // so we don't accidentally rewrite the close-tag's `</github-content`).
64
+ const escapedBody = text
65
+ .split('&')
66
+ .join(AMP_ESCAPE)
67
+ .split(UNTRUSTED_CLOSE_TAG)
68
+ .join(CLOSE_TAG_ESCAPE)
69
+ .replace(OPEN_TAG_PATTERN, OPEN_TAG_ESCAPE);
70
+ const attrs = [`label="${escapeAttr(label)}"`];
71
+ if (meta.author !== undefined)
72
+ attrs.push(`author="${escapeAttr(meta.author)}"`);
73
+ if (meta.association !== undefined)
74
+ attrs.push(`association="${escapeAttr(meta.association)}"`);
75
+ if (meta.source !== undefined)
76
+ attrs.push(`source="${escapeAttr(meta.source)}"`);
77
+ return `<${UNTRUSTED_OPEN_TAG_NAME} ${attrs.join(' ')}>${escapedBody}${UNTRUSTED_CLOSE_TAG}`;
78
+ }
79
+ /**
80
+ * Reverse of {@link wrapUntrustedContent}. Extracts the original body text
81
+ * (un-escaping any sentinel-encoded close tags). Throws if the input is not
82
+ * a single well-formed fence — callers shouldn't be parsing arbitrary
83
+ * markdown with this; it's for tests + symmetric reasoning only.
84
+ */
85
+ export function extractFromFence(fenced) {
86
+ const openMatch = fenced.match(new RegExp(`^<${UNTRUSTED_OPEN_TAG_NAME}\\b[^>]*>`));
87
+ if (!openMatch) {
88
+ throw new Error('extractFromFence: input does not start with a <github-content> open tag');
89
+ }
90
+ if (!fenced.endsWith(UNTRUSTED_CLOSE_TAG)) {
91
+ throw new Error('extractFromFence: input does not end with </github-content>');
92
+ }
93
+ const inner = fenced.slice(openMatch[0].length, fenced.length - UNTRUSTED_CLOSE_TAG.length);
94
+ if (inner.includes(UNTRUSTED_CLOSE_TAG)) {
95
+ throw new Error('extractFromFence: nested </github-content> found in body — fence escaping is broken');
96
+ }
97
+ // Reverse the escapes in opposite order: tag entities first (so an
98
+ // already-`&amp;`-prefixed entity survives), then unescape `&amp;`.
99
+ return inner
100
+ .split(CLOSE_TAG_ESCAPE)
101
+ .join(UNTRUSTED_CLOSE_TAG)
102
+ .split(OPEN_TAG_ESCAPE)
103
+ .join(`<${UNTRUSTED_OPEN_TAG_NAME}`)
104
+ .split(AMP_ESCAPE)
105
+ .join('&');
106
+ }
package/dist/core/urls.js CHANGED
@@ -5,8 +5,8 @@
5
5
  * owner/repo characters. Extracted from utils.ts under #1116.
6
6
  */
7
7
  // Validation patterns for GitHub owner and repo names
8
- const OWNER_PATTERN = /^[a-zA-Z0-9_-]+$/;
9
- const REPO_PATTERN = /^[a-zA-Z0-9_.-]+$/;
8
+ const OWNER_PATTERN = /^[\w-]+$/;
9
+ const REPO_PATTERN = /^[\w.-]+$/;
10
10
  function isValidOwnerRepo(owner, repo) {
11
11
  return OWNER_PATTERN.test(owner) && REPO_PATTERN.test(repo);
12
12
  }
@@ -3,10 +3,9 @@
3
3
  * Provides structured output that can be consumed by scripts and plugins
4
4
  */
5
5
  import { z, type ZodType } from 'zod';
6
- import type { FetchedPR, DailyDigest, AgentState, RepoGroup, CommentedIssue, ShelvedPRRef } from '../core/types.js';
6
+ import type { FetchedPR, DailyDigest, AgentState, RepoGroup, CommentedIssue, ShelvedPRRef, SearchPriority, CapacityAssessment, ActionableIssue, ActionableIssueType, CompactActionableIssue, ActionMenuItem, ActionMenu } from '../core/types.js';
7
7
  import type { ContributionStats } from '../core/stats.js';
8
8
  import type { PRCheckFailure } from '../core/pr-monitor.js';
9
- import type { SearchPriority, CapacityAssessment, ActionableIssue, ActionableIssueType, CompactActionableIssue, ActionMenuItem, ActionMenu } from '../core/types.js';
10
9
  import type { CIFormatterDiagnosis, FormatterDetectionResult } from '../core/formatter-detection.js';
11
10
  export type { CapacityAssessment, ActionableIssue, ActionableIssueType, CompactActionableIssue, ActionMenuItem, ActionMenu, };
12
11
  export type ErrorCode = 'AUTH_REQUIRED' | 'RATE_LIMITED' | 'VALIDATION' | 'CONFIGURATION' | 'NETWORK' | 'NOT_FOUND' | 'STATE_CORRUPTED' | 'CONCURRENCY' | 'UNKNOWN';
@@ -51,18 +50,37 @@ export interface CompactRepoGroup {
51
50
  * See `DailyWarning` and issue #1042 for the rationale — keeping this a
52
51
  * fixed union so downstream consumers can switch on it without drift.
53
52
  */
54
- export type DailyWarningPhase = 'fetch' | 'repo-scores' | 'analytics' | 'scout-sync' | 'partition' | 'dismiss-filter' | 'gist-checkpoint';
53
+ export type DailyWarningPhase = 'fetch' | 'repo-scores' | 'analytics' | 'scout-sync' | 'partition' | 'dismiss-filter' | 'gist-checkpoint' | 'gist-staleness';
55
54
  /**
56
55
  * A single non-fatal failure surfaced from the `daily` pipeline. Unlike
57
56
  * `PRCheckFailure` (which is scoped to per-PR fetch errors), this covers
58
57
  * ancillary fetches that previously demoted to a log-only `warn()` — repo
59
58
  * metadata, monthly analytics, scout sync, Gist checkpoint, etc.
59
+ *
60
+ * `timestamp` and `details` are optional structured extensions added in
61
+ * #1193 so staleness warnings can carry `lastSuccessfulRefresh` /
62
+ * `detectedAt` without bespoke schemas. Existing producers don't need to
63
+ * supply them.
60
64
  */
61
65
  export interface DailyWarning {
62
66
  phase: DailyWarningPhase;
63
67
  operation: string;
64
68
  message: string;
69
+ timestamp?: string;
70
+ details?: Record<string, unknown>;
65
71
  }
72
+ /**
73
+ * Build a warning entry from a {@link StalenessInfo} marker (#1193).
74
+ * Co-located with `DailyWarning` so every command (`daily`, `status`,
75
+ * `comments`) builds the same shape from the same source.
76
+ */
77
+ export interface StalenessLike {
78
+ source: string;
79
+ reason: string;
80
+ lastSuccessfulRefresh: string | null;
81
+ detectedAt: string;
82
+ }
83
+ export declare function buildStalenessWarning(info: StalenessLike): DailyWarning;
66
84
  export interface DailyOutput {
67
85
  digest: DailyDigestCompact;
68
86
  capacity: CapacityAssessment;
@@ -139,6 +157,22 @@ export declare const StatusOutputSchema: z.ZodObject<{
139
157
  lastRunAt: z.ZodString;
140
158
  offline: z.ZodOptional<z.ZodBoolean>;
141
159
  lastUpdated: z.ZodOptional<z.ZodString>;
160
+ warnings: z.ZodOptional<z.ZodArray<z.ZodObject<{
161
+ phase: z.ZodEnum<{
162
+ fetch: "fetch";
163
+ "repo-scores": "repo-scores";
164
+ analytics: "analytics";
165
+ "scout-sync": "scout-sync";
166
+ partition: "partition";
167
+ "dismiss-filter": "dismiss-filter";
168
+ "gist-checkpoint": "gist-checkpoint";
169
+ "gist-staleness": "gist-staleness";
170
+ }>;
171
+ operation: z.ZodString;
172
+ message: z.ZodString;
173
+ timestamp: z.ZodOptional<z.ZodString>;
174
+ details: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
175
+ }, z.core.$strip>>>;
142
176
  }, z.core.$strip>;
143
177
  export type StatusOutput = z.infer<typeof StatusOutputSchema>;
144
178
  export declare const DailyOutputSchema: z.ZodObject<{
@@ -234,9 +268,12 @@ export declare const DailyOutputSchema: z.ZodObject<{
234
268
  partition: "partition";
235
269
  "dismiss-filter": "dismiss-filter";
236
270
  "gist-checkpoint": "gist-checkpoint";
271
+ "gist-staleness": "gist-staleness";
237
272
  }>;
238
273
  operation: z.ZodString;
239
274
  message: z.ZodString;
275
+ timestamp: z.ZodOptional<z.ZodString>;
276
+ details: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
240
277
  }, z.core.$strip>>;
241
278
  }, z.core.$strip>;
242
279
  export declare const CompactDailyOutputSchema: z.ZodObject<{
@@ -327,9 +364,12 @@ export declare const CompactDailyOutputSchema: z.ZodObject<{
327
364
  partition: "partition";
328
365
  "dismiss-filter": "dismiss-filter";
329
366
  "gist-checkpoint": "gist-checkpoint";
367
+ "gist-staleness": "gist-staleness";
330
368
  }>;
331
369
  operation: z.ZodString;
332
370
  message: z.ZodString;
371
+ timestamp: z.ZodOptional<z.ZodString>;
372
+ details: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
333
373
  }, z.core.$strip>>;
334
374
  }, z.core.$strip>;
335
375
  export declare const SearchOutputSchema: z.ZodObject<{
@@ -426,6 +466,14 @@ export declare const InitOutputSchema: z.ZodObject<{
426
466
  username: z.ZodString;
427
467
  message: z.ZodString;
428
468
  }, z.core.$strip>;
469
+ export declare const ManifestOutputSchema: z.ZodObject<{
470
+ schemaVersion: z.ZodLiteral<1>;
471
+ cliVersion: z.ZodString;
472
+ commands: z.ZodArray<z.ZodObject<{
473
+ name: z.ZodString;
474
+ localOnly: z.ZodBoolean;
475
+ }, z.core.$strip>>;
476
+ }, z.core.$strip>;
429
477
  export declare const CheckSetupOutputSchema: z.ZodObject<{
430
478
  setupComplete: z.ZodBoolean;
431
479
  username: z.ZodString;
@@ -791,6 +839,8 @@ export interface CommentsOutput {
791
839
  inlineCommentCount: number;
792
840
  discussionCommentCount: number;
793
841
  };
842
+ /** Non-fatal warnings (e.g. stale-cache fallback, #1193). Always present; empty on clean runs. */
843
+ warnings?: DailyWarning[];
794
844
  }
795
845
  /** Output of the post command */
796
846
  export interface PostOutput {