@aperant/framework 0.10.0 → 0.11.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 (72) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/cli/commands/catch-up.d.mts +9 -0
  3. package/dist/cli/commands/catch-up.d.mts.map +1 -0
  4. package/dist/cli/commands/catch-up.mjs +64 -0
  5. package/dist/cli/commands/catch-up.mjs.map +1 -0
  6. package/dist/cli/commands/pr-review.d.mts +16 -4
  7. package/dist/cli/commands/pr-review.d.mts.map +1 -1
  8. package/dist/cli/commands/pr-review.mjs +30 -11
  9. package/dist/cli/commands/pr-review.mjs.map +1 -1
  10. package/dist/cli/commands/route.d.mts.map +1 -1
  11. package/dist/cli/commands/route.mjs +92 -2
  12. package/dist/cli/commands/route.mjs.map +1 -1
  13. package/dist/cli/commands/validate-evidence.d.mts +1 -1
  14. package/dist/cli/config/share-policy.d.mts.map +1 -1
  15. package/dist/cli/config/share-policy.mjs +1 -0
  16. package/dist/cli/config/share-policy.mjs.map +1 -1
  17. package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
  18. package/dist/cli/config/upgrade-gitignore.mjs +1 -0
  19. package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
  20. package/dist/cli/coordination/catch-up.d.mts +26 -0
  21. package/dist/cli/coordination/catch-up.d.mts.map +1 -0
  22. package/dist/cli/coordination/catch-up.mjs +239 -0
  23. package/dist/cli/coordination/catch-up.mjs.map +1 -0
  24. package/dist/cli/coordination/last-seen.d.mts +45 -0
  25. package/dist/cli/coordination/last-seen.d.mts.map +1 -0
  26. package/dist/cli/coordination/last-seen.mjs +128 -0
  27. package/dist/cli/coordination/last-seen.mjs.map +1 -0
  28. package/dist/cli/coordination/store.d.mts +15 -0
  29. package/dist/cli/coordination/store.d.mts.map +1 -1
  30. package/dist/cli/coordination/store.mjs +16 -0
  31. package/dist/cli/coordination/store.mjs.map +1 -1
  32. package/dist/cli/design/frontmatter-schema.d.mts +8 -8
  33. package/dist/cli/dispatch.d.mts.map +1 -1
  34. package/dist/cli/dispatch.mjs +2 -0
  35. package/dist/cli/dispatch.mjs.map +1 -1
  36. package/dist/cli/git/default-branch.d.mts +51 -0
  37. package/dist/cli/git/default-branch.d.mts.map +1 -0
  38. package/dist/cli/git/default-branch.mjs +234 -0
  39. package/dist/cli/git/default-branch.mjs.map +1 -0
  40. package/dist/cli/git/identity.d.mts +3 -5
  41. package/dist/cli/git/identity.d.mts.map +1 -1
  42. package/dist/cli/git/identity.mjs +10 -4
  43. package/dist/cli/git/identity.mjs.map +1 -1
  44. package/dist/cli/install/legacy-paths.d.mts.map +1 -1
  45. package/dist/cli/install/legacy-paths.mjs +2 -0
  46. package/dist/cli/install/legacy-paths.mjs.map +1 -1
  47. package/dist/cli/personas/sidecar.d.mts +1 -1
  48. package/dist/cli/roadmap/lifecycle.d.mts +1 -1
  49. package/dist/cli/roadmap/rollup.d.mts +2 -2
  50. package/dist/plugin/.claude-plugin/plugin.json +2 -1
  51. package/dist/plugin/skills/apt/SKILL.md +29 -0
  52. package/dist/plugin/skills/apt-catch-up/SKILL.md +79 -0
  53. package/dist/plugin/skills/apt-pr-review/SKILL.md +5 -4
  54. package/dist/plugin/skills/apt-setup/SKILL.md +76 -0
  55. package/package.json +138 -138
  56. package/skills/apt/SKILL.md +29 -0
  57. package/skills/apt-catch-up/SKILL.md +79 -0
  58. package/skills/apt-pr-review/SKILL.md +5 -4
  59. package/skills/apt-setup/SKILL.md +76 -0
  60. package/src/cli/commands/catch-up.mjs +67 -0
  61. package/src/cli/commands/pr-review.mjs +32 -11
  62. package/src/cli/commands/route.mjs +92 -1
  63. package/src/cli/config/share-policy.mjs +1 -0
  64. package/src/cli/config/upgrade-gitignore.mjs +1 -0
  65. package/src/cli/coordination/catch-up.mjs +231 -0
  66. package/src/cli/coordination/last-seen.mjs +131 -0
  67. package/src/cli/coordination/store.mjs +18 -0
  68. package/src/cli/dispatch.mjs +2 -0
  69. package/src/cli/git/default-branch.mjs +250 -0
  70. package/src/cli/git/identity.mjs +9 -3
  71. package/src/cli/install/legacy-paths.mjs +2 -0
  72. package/templates/config.json +11 -0
@@ -727,17 +727,30 @@ function parseGhPrView(raw) {
727
727
  * the viewer's personal handle (e.g. `AndyMik90`). They never match, which
728
728
  * previously caused every same-repo, owned PR to be mis-classified as
729
729
  * `external` and silently disabled auto-fix. The third arg `baseRepoOwner`
730
- * is the source of truth for "is this a fork PR?". `viewerLogin` is kept
731
- * only for backward-compatible signal reporting.
730
+ * is the source of truth for "is this a fork PR?".
731
+ *
732
+ * IMPORTANT (G44): "owned" means "a PR I authored," NOT "a branch in a repo I
733
+ * can push to." The owned test is strictly `gh.author.login === viewerLogin`
734
+ * — author IDENTITY, not `authorAssociation`. Previously a same-repo PR with
735
+ * `authorAssociation ∈ {OWNER, MEMBER, COLLABORATOR}` was classified `owned`,
736
+ * which meant every teammate's same-repo PR triggered the auto-fix-and-push
737
+ * pipeline on their branch. Now `authorAssociation` is retained only in the
738
+ * signal for human-readable tracing; it no longer gates the decision. The
739
+ * "is this mine" comparand is `gh.author?.login`, never `headRepoOwner`
740
+ * (comparing headRepoOwner to viewerLogin was the H1 bug — it never matches
741
+ * on an org-owned repo). Fork detection and author identity are independent:
742
+ * fork detection answers "did this come from a fork?"; author identity
743
+ * answers "did I write it?".
732
744
  *
733
745
  * @param {Record<string, any> | null | undefined} gh parsed `gh pr view` JSON (may have `authorAssociation` grafted in by the REST-fetch path)
734
- * @param {string | null | undefined} viewerLogin personal login from `gh api user` (fallback comparand for fork detection)
735
- * @param {string | null | undefined} [baseRepoOwner] the org/user that owns the repo the PR targets (preferred comparand)
746
+ * @param {string | null | undefined} viewerLogin personal login from `gh api user` (compared to `gh.author.login` for the owned test)
747
+ * @param {string | null | undefined} [baseRepoOwner] the org/user that owns the repo the PR targets (fork-detection comparand)
736
748
  * @returns {{ authorship_mode: 'owned' | 'external' | 'unknown', authorship_signal: string }}
737
749
  */
738
750
  export function decideAuthorshipFromGh(gh, viewerLogin, baseRepoOwner = null) {
739
751
  if (!gh) return { authorship_mode: 'unknown', authorship_signal: 'gh_payload_missing' }
740
752
  const association = gh.authorAssociation ?? gh.author?.association ?? null
753
+ const author = gh.author?.login ?? null
741
754
  const headRepoOwner =
742
755
  gh.headRepository?.owner?.login ??
743
756
  gh.headRepositoryOwner?.login ??
@@ -745,28 +758,36 @@ export function decideAuthorshipFromGh(gh, viewerLogin, baseRepoOwner = null) {
745
758
  null
746
759
  // Prefer baseRepoOwner when supplied; fall back to viewerLogin only for
747
760
  // older callers that haven't been updated yet (e.g. legacy tests). The
748
- // fallback preserves prior behavior for the personal-repo case.
761
+ // fallback preserves prior fork-detection behavior for the personal-repo
762
+ // case.
749
763
  const compareOwner = baseRepoOwner ?? viewerLogin
750
764
  const isFork = headRepoOwner && compareOwner && headRepoOwner !== compareOwner
751
765
 
752
- const ownerLike = ['OWNER', 'MEMBER', 'COLLABORATOR']
753
766
  const externalLike = ['NONE', 'CONTRIBUTOR', 'FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER']
754
767
 
755
- if (!isFork && association && ownerLike.includes(association)) {
768
+ // G44: owned ⟺ (not a fork) AND the PR was authored by the running viewer.
769
+ if (!isFork && author && viewerLogin && author === viewerLogin) {
756
770
  return {
757
771
  authorship_mode: 'owned',
758
- authorship_signal: `authorAssociation=${association}, same-repo branch (head=${headRepoOwner ?? '?'}, base=${compareOwner ?? '?'})`,
772
+ authorship_signal: `authored-by-viewer (author=${author}, viewer=${viewerLogin}), same-repo branch (head=${headRepoOwner ?? '?'}, base=${compareOwner ?? '?'}), authorAssociation=${association ?? 'null'}`,
759
773
  }
760
774
  }
761
- if (isFork || (association && externalLike.includes(association))) {
775
+ // Forks, external-like associations, AND non-fork PRs authored by someone
776
+ // other than the viewer (a teammate's same-repo PR — the G44 case) all
777
+ // resolve to external (comment-only).
778
+ if (
779
+ isFork ||
780
+ (association && externalLike.includes(association)) ||
781
+ (!isFork && author && viewerLogin && author !== viewerLogin)
782
+ ) {
762
783
  return {
763
784
  authorship_mode: 'external',
764
- authorship_signal: `authorAssociation=${association}, fork=${Boolean(isFork)} (head=${headRepoOwner ?? '?'}, base=${compareOwner ?? '?'})`,
785
+ authorship_signal: `authorAssociation=${association ?? 'null'}, fork=${Boolean(isFork)}, author=${author ?? '?'}, viewer=${viewerLogin ?? '?'} (head=${headRepoOwner ?? '?'}, base=${compareOwner ?? '?'})`,
765
786
  }
766
787
  }
767
788
  return {
768
789
  authorship_mode: 'unknown',
769
- authorship_signal: `authorAssociation=${association ?? 'null'}, fork=${Boolean(isFork)}`,
790
+ authorship_signal: `authorAssociation=${association ?? 'null'}, fork=${Boolean(isFork)}, author=${author ?? 'null'}`,
770
791
  }
771
792
  }
772
793
 
@@ -32,7 +32,12 @@ import {
32
32
  getTaskIsolationConfig,
33
33
  getTrustedSkillSources,
34
34
  loadMergedProjectConfig,
35
+ loadProjectConfig,
35
36
  } from '../config/load.mjs'
37
+ import { resolveSharePolicy } from '../config/share-policy.mjs'
38
+ import { computeCatchUpPanel } from '../coordination/catch-up.mjs'
39
+ import { createCoordinationStore } from '../coordination/store.mjs'
40
+ import { resolveLocalDefaultBranch } from '../git/default-branch.mjs'
36
41
  import '../gate/gates/index.mjs'
37
42
  import * as gateRegistry from '../gate/registry.mjs'
38
43
  import { detectHost, SUPPORTED_HOSTS } from '../host/detect.mjs'
@@ -96,6 +101,78 @@ function readUpdateCheckCache() {
96
101
  return null
97
102
  }
98
103
 
104
+ /**
105
+ * Is this an interactive (TTY) apt call? The catch-up panel + Last-Seen Marker
106
+ * are gated to interactive contexts (LD-02) — exactly like the statusline
107
+ * isTTY gate — so spawned subagents, fan-out workers, and non-TTY/piped calls
108
+ * are strict no-ops (they neither read nor write the marker). `APT_FORCE_TTY`
109
+ * forces the gate open for tests; `APT_CATCH_UP_DISABLE` forces it closed so a
110
+ * background spawner can opt every nested call out.
111
+ */
112
+ function isInteractiveTty() {
113
+ if (process.env.APT_CATCH_UP_DISABLE === '1') return false
114
+ if (process.env.APT_FORCE_TTY === '1') return true
115
+ return Boolean(process.stdout?.isTTY || process.stderr?.isTTY)
116
+ }
117
+
118
+ /**
119
+ * Lazy-compute the catch-up panel for an interactive call and advance the
120
+ * Last-Seen Marker AFTER the panel is evaluated (R2/LD-02). Returns the panel
121
+ * (or null) to fold onto the route envelope as a top-level `catch_up` field.
122
+ *
123
+ * Marker-advance ordering (correctness-critical):
124
+ * - last_run_at = now — always (engagement, not greeting);
125
+ * - last_main_sha = defaultBranch.sha — only AFTER the digest was computed,
126
+ * i.e. only when we resolved a branch
127
+ * (even when the digest is empty);
128
+ * - last_active_task = welcome-back task id, when present.
129
+ *
130
+ * Non-TTY callers never reach this function (the caller gates on
131
+ * isInteractiveTty), so background/spawned calls never touch the marker.
132
+ *
133
+ * @param {string} targetDir
134
+ * @returns {object|null} the catch_up panel, or null.
135
+ */
136
+ function computeCatchUpForRoute(targetDir) {
137
+ // Setup mirrors cmdCatchUp in commands/catch-up.mjs (recompute-only variant).
138
+ const config = loadProjectConfig(targetDir)
139
+ if (!config) return null
140
+
141
+ const store = createCoordinationStore(targetDir)
142
+ const marker = store.readLastSeen()
143
+ const td = config?.collaboration?.team_digest
144
+ const vis = resolveSharePolicy(config).visibility
145
+ const needsDigest = td?.enabled !== false && (vis === 'team' || vis === 'oss')
146
+ const defaultBranch = needsDigest ? resolveLocalDefaultBranch(targetDir) : null
147
+ const now = Date.now()
148
+
149
+ let panel = null
150
+ try {
151
+ panel = computeCatchUpPanel(targetDir, {
152
+ config,
153
+ now,
154
+ marker,
155
+ defaultBranch,
156
+ })
157
+ } catch (e) {
158
+ console.warn('[apt] catch-up:', e?.message ?? e)
159
+ panel = null
160
+ }
161
+
162
+ // Advance the marker AFTER panel eval. last_main_sha only when we actually
163
+ // resolved a default branch (the digest was computed, possibly empty).
164
+ const advance = { last_run_at: new Date(now).toISOString() }
165
+ if (defaultBranch?.sha) advance.last_main_sha = defaultBranch.sha
166
+ if (panel?.welcome_back?.task_id) advance.last_active_task = panel.welcome_back.task_id
167
+ try {
168
+ store.advanceLastSeen(advance)
169
+ } catch {
170
+ /* best-effort — never block routing on a marker write */
171
+ }
172
+
173
+ return panel
174
+ }
175
+
99
176
  /**
100
177
  * Deterministic mapping from an explicit track flag (--quick/--deep/--debug)
101
178
  * to its canonical {track, primary_skill} pair. Flag overrides are user
@@ -309,6 +386,13 @@ export function cmdRoute(projectDir, extraArgs) {
309
386
  }
310
387
  }
311
388
 
389
+ // Catch-up panel (LD-11): lazy-compute on interactive (TTY) calls only, and
390
+ // only when config exists (init has no config yet). The compute also
391
+ // advances the Last-Seen Marker after panel eval (LD-02). Non-TTY calls are
392
+ // strict no-ops — no read, no write, no field. Folded onto every returned
393
+ // envelope below as a top-level `catch_up` field, mirroring update_check.
394
+ const catch_up = hasConfig && isInteractiveTty() ? computeCatchUpForRoute(targetDir) : null
395
+
312
396
  // ---------------- Deterministic branches (zero-behavior-change) ----------------
313
397
  // These fire regardless of host detection status — missing host is not
314
398
  // fatal for init/status/help/skill-passthrough.
@@ -322,6 +406,7 @@ export function cmdRoute(projectDir, extraArgs) {
322
406
  host_capabilities,
323
407
  task_isolation,
324
408
  update_check,
409
+ catch_up: null,
325
410
  reason: 'No .aperant/config.json found — first-time setup required',
326
411
  })
327
412
  }
@@ -335,6 +420,7 @@ export function cmdRoute(projectDir, extraArgs) {
335
420
  host_capabilities,
336
421
  task_isolation,
337
422
  update_check,
423
+ catch_up,
338
424
  reason: 'No input provided — showing project status',
339
425
  })
340
426
  }
@@ -348,6 +434,7 @@ export function cmdRoute(projectDir, extraArgs) {
348
434
  host_capabilities,
349
435
  task_isolation,
350
436
  update_check,
437
+ catch_up,
351
438
  reason: 'Help requested',
352
439
  })
353
440
  }
@@ -395,6 +482,7 @@ export function cmdRoute(projectDir, extraArgs) {
395
482
  host_capabilities,
396
483
  task_isolation,
397
484
  update_check,
485
+ catch_up,
398
486
  ...(gate_preview ? { gate_preview } : {}),
399
487
  reason: `Direct skill invocation: apt:${firstWord}`,
400
488
  })
@@ -444,6 +532,7 @@ export function cmdRoute(projectDir, extraArgs) {
444
532
  host_capabilities,
445
533
  task_isolation,
446
534
  update_check,
535
+ catch_up,
447
536
  ...(gate_preview ? { gate_preview } : {}),
448
537
  ...(spar_inject_gates ? { spar_inject_gates } : {}),
449
538
  classification: {
@@ -523,7 +612,9 @@ export function cmdRoute(projectDir, extraArgs) {
523
612
  )
524
613
  }
525
614
 
526
- return ok(envelope)
615
+ // Fold the catch-up panel onto the classify envelope as a top-level field,
616
+ // mirroring update_check (LD-11). null when non-TTY / neither half fires.
617
+ return ok({ ...envelope, catch_up })
527
618
  }
528
619
 
529
620
  /**
@@ -254,6 +254,7 @@ export function buildPolicyIgnoreMatcher(policy) {
254
254
  rest.startsWith('events/') ||
255
255
  rest.startsWith('locks/') ||
256
256
  rest.startsWith('team-status/') ||
257
+ rest.startsWith('last-seen/') ||
257
258
  rest.startsWith('paused/') ||
258
259
  rest.startsWith('rationalizations/') ||
259
260
  rest === 'digests/.last-run.json' ||
@@ -189,6 +189,7 @@ export function buildAperantGitignoreTemplate(policy = DEFAULT_SHARE_POLICY) {
189
189
  // maintainer cannot accidentally un-ignore it with a broad `!config*.json`
190
190
  // allow rule.
191
191
  lines.push('config.local.json')
192
+ lines.push('last-seen/')
192
193
  lines.push('')
193
194
  lines.push('# ── Derived markdown indexes (regenerated from JSON) ──────────────')
194
195
  lines.push('tasks/INDEX.md')
@@ -0,0 +1,231 @@
1
+ /**
2
+ * @internal
3
+ * coordination/catch-up.mjs — composes the combined catch-up panel: the
4
+ * Welcome-Back Summary (your own half-finished work, local-only) and the
5
+ * gated Team-Change Digest (what teammates merged while you were away).
6
+ *
7
+ * Composition order is fixed: welcome_back first, then team_digest (LD-10).
8
+ * The renderer (apt/SKILL.md) enforces display order; this module only sets
9
+ * the field order on the returned object.
10
+ *
11
+ * Both halves diff against one Last-Seen Marker:
12
+ * - Welcome-Back fires on a staleness threshold (now - last_run_at), reads
13
+ * only local state.active_tasks, and is ALWAYS-ON regardless of visibility
14
+ * (LD-08/09).
15
+ * - Team-Change Digest fires only when share.visibility is team/oss AND the
16
+ * local default-branch sha has moved since last_main_sha (LD-04/06/07).
17
+ *
18
+ * Returns null when neither half fires. This module is `@internal` — reached
19
+ * via the route.mjs lazy-compute path and the apt-tools catch-up command, not
20
+ * from bin/apt-tools.mjs directly.
21
+ */
22
+
23
+ import { existsSync, readFileSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+ import { resolveSharePolicy } from '../config/share-policy.mjs'
26
+ import { listMergedPullRequests } from '../git/default-branch.mjs'
27
+ import { getGitIdentity } from '../git/identity.mjs'
28
+ import { resolveSharedRoot } from './agent-identity.mjs'
29
+
30
+ const HOUR_MS = 60 * 60 * 1000
31
+
32
+ /**
33
+ * Best-available per-task activity timestamp (ms). Prefers an explicit
34
+ * updated_at, then the most recent history entry, then created_at.
35
+ *
36
+ * @param {object} task
37
+ * @returns {number} epoch ms, or 0 when no timestamp is parseable.
38
+ */
39
+ function taskActivityMs(task) {
40
+ const candidates = []
41
+ if (task?.updated_at) candidates.push(Date.parse(task.updated_at))
42
+ if (Array.isArray(task?.history)) {
43
+ for (const h of task.history) {
44
+ if (h?.at) candidates.push(Date.parse(h.at))
45
+ }
46
+ }
47
+ if (task?.created_at) candidates.push(Date.parse(task.created_at))
48
+ const valid = candidates.filter((n) => Number.isFinite(n))
49
+ return valid.length ? Math.max(...valid) : 0
50
+ }
51
+
52
+ /**
53
+ * A coarse "x ago" relative-time string. Kept dependency-free and stable for
54
+ * tests — granularity is minutes/hours/days.
55
+ *
56
+ * @param {number} deltaMs
57
+ * @returns {string}
58
+ */
59
+ function relativeTime(deltaMs) {
60
+ if (!Number.isFinite(deltaMs) || deltaMs < 0) return 'just now'
61
+ const mins = Math.floor(deltaMs / 60000)
62
+ if (mins < 1) return 'just now'
63
+ if (mins < 60) return `${mins}m ago`
64
+ const hours = Math.floor(mins / 60)
65
+ if (hours < 24) return `${hours}h ago`
66
+ const days = Math.floor(hours / 24)
67
+ return `${days}d ago`
68
+ }
69
+
70
+ /**
71
+ * Read the first actionable next-step line from a task's CONTINUE note, if one
72
+ * exists at `.aperant/tasks/{taskId}/CONTINUE.md`. Parses the first non-empty
73
+ * item under the `## Next Steps` heading (the pause-note format).
74
+ *
75
+ * @param {string} sharedRoot
76
+ * @param {string} taskId
77
+ * @returns {string|null}
78
+ */
79
+ function readNextStepLine(sharedRoot, taskId) {
80
+ if (!taskId) return null
81
+ const notePath = join(sharedRoot, '.aperant', 'tasks', taskId, 'CONTINUE.md')
82
+ if (!existsSync(notePath)) return null
83
+ let text
84
+ try {
85
+ text = readFileSync(notePath, 'utf-8')
86
+ } catch {
87
+ return null
88
+ }
89
+ const lines = text.split('\n')
90
+ const headingIdx = lines.findIndex((l) => /^##\s+Next Steps\s*$/i.test(l.trim()))
91
+ if (headingIdx === -1) return null
92
+ for (let i = headingIdx + 1; i < lines.length; i++) {
93
+ const raw = lines[i].trim()
94
+ if (raw.startsWith('##')) break // next section
95
+ if (raw.length === 0) continue
96
+ // Strip a leading list marker: "1. ", "- ", "* ".
97
+ const cleaned = raw.replace(/^(\d+\.|[-*])\s+/, '').trim()
98
+ if (cleaned.length > 0) return cleaned
99
+ }
100
+ return null
101
+ }
102
+
103
+ /**
104
+ * Build the Welcome-Back half from local state (LD-08/09). Returns null when
105
+ * the threshold has not elapsed or there is no active task.
106
+ *
107
+ * @param {object} params
108
+ * @param {object} params.config
109
+ * @param {number} params.now epoch ms
110
+ * @param {object|null} params.marker
111
+ * @param {object|null} params.state raw state.json (for active_tasks)
112
+ * @param {string} params.sharedRoot
113
+ * @returns {object|null}
114
+ */
115
+ function buildWelcomeBack({ config, now, marker, state, sharedRoot }) {
116
+ const wb = config?.collaboration?.welcome_back
117
+ if (wb?.enabled === false) return null
118
+
119
+ const lastRunAt = marker?.last_run_at ? Date.parse(marker.last_run_at) : NaN
120
+ const thresholdHours = Number.isFinite(wb?.threshold_hours) ? wb.threshold_hours : 16
121
+ // Only fire when we have a baseline AND the gap strictly exceeds the threshold.
122
+ if (!Number.isFinite(lastRunAt)) return null
123
+ if (now - lastRunAt <= thresholdHours * HOUR_MS) return null
124
+
125
+ const activeTasks =
126
+ state?.active_tasks && typeof state.active_tasks === 'object' ? state.active_tasks : {}
127
+ const ids = Object.keys(activeTasks)
128
+ if (ids.length === 0) return null
129
+
130
+ // Pick the single most-recently-active task.
131
+ let bestId = null
132
+ let bestMs = -1
133
+ for (const id of ids) {
134
+ const ms = taskActivityMs(activeTasks[id])
135
+ if (ms > bestMs) {
136
+ bestMs = ms
137
+ bestId = id
138
+ }
139
+ }
140
+ if (!bestId) return null
141
+
142
+ const task = activeTasks[bestId]
143
+ const currentSubtask = task?.progress?.current_subtask ?? task?.current_subtask ?? null
144
+
145
+ return {
146
+ task_id: bestId,
147
+ description: task?.description ?? bestId,
148
+ lifecycle_phase: task?.lifecycle_phase ?? null,
149
+ current_subtask: currentSubtask,
150
+ progress: task?.progress ?? null,
151
+ relative_time: bestMs > 0 ? relativeTime(now - bestMs) : null,
152
+ next_step: readNextStepLine(sharedRoot, bestId),
153
+ others_count: Math.max(0, ids.length - 1),
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Build the Team-Change Digest half (LD-04/06/07). Returns null when the
159
+ * visibility gate is closed (solo), the digest is disabled, or the
160
+ * default-branch sha has not moved since the marker.
161
+ *
162
+ * @param {object} params
163
+ * @param {string} params.projectRoot
164
+ * @param {object} params.config
165
+ * @param {object|null} params.marker
166
+ * @param {{ branch: string, sha: string }|null} params.defaultBranch
167
+ * @returns {object|null}
168
+ */
169
+ function buildTeamDigest({ projectRoot, config, marker, defaultBranch }) {
170
+ const td = config?.collaboration?.team_digest
171
+ if (td?.enabled === false) return null
172
+
173
+ const visibility = resolveSharePolicy(config).visibility
174
+ if (visibility !== 'team' && visibility !== 'oss') return null
175
+
176
+ if (!defaultBranch?.sha) return null
177
+ // Only fire when main has actually moved since the recorded baseline.
178
+ if (marker?.last_main_sha && marker.last_main_sha === defaultBranch.sha) return null
179
+
180
+ const maxEntries = Number.isInteger(td?.max_entries) && td.max_entries > 0 ? td.max_entries : 7
181
+ const identity = (() => {
182
+ try {
183
+ return getGitIdentity(projectRoot)
184
+ } catch {
185
+ return null
186
+ }
187
+ })()
188
+
189
+ const { entries, more_count } = listMergedPullRequests(projectRoot, {
190
+ sinceSha: marker?.last_main_sha || null,
191
+ defaultBranch: defaultBranch.branch,
192
+ currentUserEmail: identity?.email || '',
193
+ maxEntries,
194
+ })
195
+
196
+ if (entries.length === 0 && more_count === 0) return null
197
+ return { entries, more_count }
198
+ }
199
+
200
+ /**
201
+ * Compose the combined catch-up panel. Returns null when neither half fires.
202
+ *
203
+ * @param {string} projectRoot
204
+ * @param {object} params
205
+ * @param {object} params.config
206
+ * @param {number} params.now epoch ms
207
+ * @param {object|null} params.marker Last-Seen Marker
208
+ * @param {{ branch: string, sha: string }|null} params.defaultBranch
209
+ * @returns {{ welcome_back: object|null, team_digest: object|null }|null}
210
+ *
211
+ * @internal
212
+ */
213
+ export function computeCatchUpPanel(projectRoot, { config, now, marker, defaultBranch } = {}) {
214
+ const sharedRoot = resolveSharedRoot(projectRoot)
215
+ const state = (() => {
216
+ const statePath = join(sharedRoot, '.aperant', 'state.json')
217
+ if (!existsSync(statePath)) return null
218
+ try {
219
+ return JSON.parse(readFileSync(statePath, 'utf-8'))
220
+ } catch {
221
+ return null
222
+ }
223
+ })()
224
+
225
+ const welcome_back = buildWelcomeBack({ config, now, marker, state, sharedRoot })
226
+ const team_digest = buildTeamDigest({ projectRoot, config, marker, defaultBranch })
227
+
228
+ if (!welcome_back && !team_digest) return null
229
+ // Fixed field order: welcome_back first (LD-10).
230
+ return { welcome_back, team_digest }
231
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @internal
3
+ * last-seen.mjs — per-machine, per-git-identity Last-Seen Marker (LD-01).
4
+ *
5
+ * The collaboration catch-up surfaces (Welcome-Back Summary + Team-Change
6
+ * Digest) both diff against one baseline record per human/machine:
7
+ *
8
+ * .aperant/last-seen/{email-slug}.json → { last_run_at, last_main_sha, last_active_task }
9
+ *
10
+ * The slug is derived from the git `user.email` (stable per human/machine),
11
+ * lowercased with every non-[a-z0-9] run collapsed to '-'. The directory is
12
+ * gitignored in the same per-machine ephemeral class as `team-status/` and
13
+ * `events/` (see upgrade-gitignore.mjs / share-policy.mjs).
14
+ *
15
+ * Modeled on team-status.mjs: resolveSharedRoot-based path so the marker lives
16
+ * in the main repo even when called from a worktree; atomicWriteJson +
17
+ * mkdirSync(recursive) for durable writes.
18
+ *
19
+ * This module is `@internal` — consumed only through the CoordinationStore
20
+ * seam (store.readLastSeen / store.advanceLastSeen). Do NOT import it from
21
+ * bin/apt-tools.mjs.
22
+ */
23
+
24
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+ import { getGitIdentity } from '../git/identity.mjs'
27
+ import { atomicWriteJson } from '../util/io.mjs'
28
+ import { resolveSharedRoot } from './agent-identity.mjs'
29
+
30
+ function lastSeenDir(projectRoot) {
31
+ return join(resolveSharedRoot(projectRoot), '.aperant', 'last-seen')
32
+ }
33
+
34
+ /**
35
+ * Slug a git email into a filesystem-safe key: lowercase, every run of
36
+ * non-[a-z0-9] characters collapsed to a single '-'.
37
+ *
38
+ * @param {string} email
39
+ * @returns {string}
40
+ */
41
+ export function slugFromEmail(email) {
42
+ return String(email)
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, '-')
45
+ .replace(/^-+|-+$/g, '')
46
+ }
47
+
48
+ function markerPath(projectRoot) {
49
+ const identity = (() => {
50
+ try {
51
+ return getGitIdentity(projectRoot)
52
+ } catch {
53
+ return null
54
+ }
55
+ })()
56
+ if (!identity?.email) return null
57
+ const slug = slugFromEmail(identity.email)
58
+ if (!slug) return null
59
+ return join(lastSeenDir(projectRoot), `${slug}.json`)
60
+ }
61
+
62
+ /**
63
+ * Read the current user's Last-Seen Marker. Returns null when the git email is
64
+ * absent (no identity → no key) or the file does not yet exist / is malformed.
65
+ *
66
+ * @param {string} projectRoot
67
+ * @returns {{ last_run_at: string|null, last_main_sha: string|null, last_active_task: string|null }|null}
68
+ *
69
+ * @internal
70
+ */
71
+ export function readLastSeen(projectRoot) {
72
+ const path = markerPath(projectRoot)
73
+ if (!path || !existsSync(path)) return null
74
+ try {
75
+ const record = JSON.parse(readFileSync(path, 'utf-8'))
76
+ if (!record || typeof record !== 'object') return null
77
+ return {
78
+ last_run_at: record.last_run_at ?? null,
79
+ last_main_sha: record.last_main_sha ?? null,
80
+ last_active_task: record.last_active_task ?? null,
81
+ }
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Advance the marker, merging partial fields onto any existing record.
89
+ *
90
+ * Marker-advance semantics (LD-02):
91
+ * - last_run_at is always settable (it measures engagement).
92
+ * - last_main_sha is optional — the caller passes it only AFTER the digest
93
+ * was computed (even when empty). When undefined, the existing sha is kept.
94
+ * - last_active_task is optional — the welcome-back task id, kept otherwise.
95
+ *
96
+ * No-op (returns null) when the git email is absent — there is no key to write.
97
+ *
98
+ * @param {string} projectRoot
99
+ * @param {{ last_run_at?: string, last_main_sha?: string|null, last_active_task?: string|null }} fields
100
+ * @returns {object|null} the written record, or null when there is no identity key.
101
+ *
102
+ * @internal
103
+ */
104
+ export function advanceLastSeen(projectRoot, fields = {}) {
105
+ const path = markerPath(projectRoot)
106
+ if (!path) return null
107
+
108
+ let existing = {}
109
+ if (existsSync(path)) {
110
+ try {
111
+ existing = JSON.parse(readFileSync(path, 'utf-8')) || {}
112
+ } catch {
113
+ existing = {}
114
+ }
115
+ }
116
+
117
+ const record = {
118
+ last_run_at:
119
+ fields.last_run_at !== undefined ? fields.last_run_at : (existing.last_run_at ?? null),
120
+ last_main_sha:
121
+ fields.last_main_sha !== undefined ? fields.last_main_sha : (existing.last_main_sha ?? null),
122
+ last_active_task:
123
+ fields.last_active_task !== undefined
124
+ ? fields.last_active_task
125
+ : (existing.last_active_task ?? null),
126
+ }
127
+
128
+ mkdirSync(lastSeenDir(projectRoot), { recursive: true })
129
+ atomicWriteJson(path, record)
130
+ return record
131
+ }
@@ -33,6 +33,7 @@ import { atomicWriteJson } from '../util/io.mjs'
33
33
  import { getAgentIdentity, resolveSharedRoot } from './agent-identity.mjs'
34
34
  import { appendLifecycleEvent } from './event-log.mjs'
35
35
  import { validateConductorEvent } from './event-schema.mjs'
36
+ import { advanceLastSeen as lastSeenAdvance, readLastSeen as lastSeenRead } from './last-seen.mjs'
36
37
  import {
37
38
  listActive as teamStatusListActive,
38
39
  removeSelf as teamStatusRemoveSelf,
@@ -152,6 +153,23 @@ export function createCoordinationStore(projectRoot) {
152
153
  }
153
154
  },
154
155
 
156
+ /**
157
+ * @internal — LD-01: read the per-machine Last-Seen Marker. Returns
158
+ * null when the git email is absent or the marker has not been written.
159
+ */
160
+ readLastSeen() {
161
+ return lastSeenRead(projectRoot)
162
+ },
163
+
164
+ /**
165
+ * @internal — LD-02: advance the Last-Seen Marker. `last_run_at` is
166
+ * always settable; `last_main_sha` / `last_active_task` are optional
167
+ * partial fields merged onto the existing record.
168
+ */
169
+ advanceLastSeen(fields = {}) {
170
+ return lastSeenAdvance(projectRoot, fields)
171
+ },
172
+
155
173
  /** @internal */
156
174
  getIdentity() {
157
175
  return getAgentIdentity(projectRoot)
@@ -39,6 +39,7 @@ import { cmdAdr } from './commands/adr.mjs'
39
39
  import { cmdAudit } from './commands/audit.mjs'
40
40
  import { cmdAuthorSkill } from './commands/author-skill.mjs'
41
41
  import { cmdBootstrap } from './commands/bootstrap.mjs'
42
+ import { cmdCatchUp } from './commands/catch-up.mjs'
42
43
  import { cmdCheck } from './commands/check.mjs'
43
44
  import { cmdCheckVersion } from './commands/check-version.mjs'
44
45
  import { cmdCiWatch, cmdCiWatchSweep } from './commands/ci-watch.mjs'
@@ -212,6 +213,7 @@ export const DISPATCH = {
212
213
 
213
214
  // ---- (positional dir, slice(2)) shape ----
214
215
  state: (args) => wrap(cmdState)(args[1]),
216
+ 'catch-up': (args) => wrap(cmdCatchUp)(args[1]),
215
217
  config: (args) => wrap(cmdConfig)(args[1], args.slice(2)),
216
218
  'state-update': (args) => wrap(cmdStateUpdate)(args[1], args.slice(2)),
217
219
  'state-artifacts': (args) => wrap(cmdStateArtifacts)(args[1], args.slice(2)),