@aperant/framework 0.9.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.
- package/CHANGELOG.md +120 -0
- package/agents/apt-verifier.md +6 -3
- package/dist/cli/commands/catch-up.d.mts +9 -0
- package/dist/cli/commands/catch-up.d.mts.map +1 -0
- package/dist/cli/commands/catch-up.mjs +64 -0
- package/dist/cli/commands/catch-up.mjs.map +1 -0
- package/dist/cli/commands/init.d.mts.map +1 -1
- package/dist/cli/commands/init.mjs +17 -1
- package/dist/cli/commands/init.mjs.map +1 -1
- package/dist/cli/commands/pr-review.d.mts +16 -4
- package/dist/cli/commands/pr-review.d.mts.map +1 -1
- package/dist/cli/commands/pr-review.mjs +30 -11
- package/dist/cli/commands/pr-review.mjs.map +1 -1
- package/dist/cli/commands/route.d.mts.map +1 -1
- package/dist/cli/commands/route.mjs +92 -2
- package/dist/cli/commands/route.mjs.map +1 -1
- package/dist/cli/commands/validate-evidence.d.mts +1 -1
- package/dist/cli/config/share-policy.d.mts.map +1 -1
- package/dist/cli/config/share-policy.mjs +1 -0
- package/dist/cli/config/share-policy.mjs.map +1 -1
- package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
- package/dist/cli/config/upgrade-gitignore.mjs +1 -0
- package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
- package/dist/cli/consistency/parse-review.mjs +5 -3
- package/dist/cli/consistency/parse-review.mjs.map +1 -1
- package/dist/cli/consistency/rules/r5-verdict-consistency.d.mts.map +1 -1
- package/dist/cli/consistency/rules/r5-verdict-consistency.mjs +10 -8
- package/dist/cli/consistency/rules/r5-verdict-consistency.mjs.map +1 -1
- package/dist/cli/coordination/catch-up.d.mts +26 -0
- package/dist/cli/coordination/catch-up.d.mts.map +1 -0
- package/dist/cli/coordination/catch-up.mjs +239 -0
- package/dist/cli/coordination/catch-up.mjs.map +1 -0
- package/dist/cli/coordination/last-seen.d.mts +45 -0
- package/dist/cli/coordination/last-seen.d.mts.map +1 -0
- package/dist/cli/coordination/last-seen.mjs +128 -0
- package/dist/cli/coordination/last-seen.mjs.map +1 -0
- package/dist/cli/coordination/store.d.mts +15 -0
- package/dist/cli/coordination/store.d.mts.map +1 -1
- package/dist/cli/coordination/store.mjs +16 -0
- package/dist/cli/coordination/store.mjs.map +1 -1
- package/dist/cli/design/frontmatter-schema.d.mts +8 -8
- package/dist/cli/dispatch.d.mts.map +1 -1
- package/dist/cli/dispatch.mjs +2 -0
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
- package/dist/cli/gate/gates/review-clean.mjs +4 -2
- package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
- package/dist/cli/git/default-branch.d.mts +51 -0
- package/dist/cli/git/default-branch.d.mts.map +1 -0
- package/dist/cli/git/default-branch.mjs +234 -0
- package/dist/cli/git/default-branch.mjs.map +1 -0
- package/dist/cli/git/identity.d.mts +3 -5
- package/dist/cli/git/identity.d.mts.map +1 -1
- package/dist/cli/git/identity.mjs +10 -4
- package/dist/cli/git/identity.mjs.map +1 -1
- package/dist/cli/install/legacy-paths.d.mts.map +1 -1
- package/dist/cli/install/legacy-paths.mjs +2 -0
- package/dist/cli/install/legacy-paths.mjs.map +1 -1
- package/dist/cli/install/mcp-provision.d.mts +26 -0
- package/dist/cli/install/mcp-provision.d.mts.map +1 -0
- package/dist/cli/install/mcp-provision.mjs +281 -0
- package/dist/cli/install/mcp-provision.mjs.map +1 -0
- package/dist/cli/install/mcp-server-specs.d.mts +80 -0
- package/dist/cli/install/mcp-server-specs.d.mts.map +1 -0
- package/dist/cli/install/mcp-server-specs.mjs +102 -0
- package/dist/cli/install/mcp-server-specs.mjs.map +1 -0
- package/dist/cli/install/runtime-detect.d.mts +1 -0
- package/dist/cli/install/runtime-detect.d.mts.map +1 -1
- package/dist/cli/install/runtime-detect.mjs +5 -0
- package/dist/cli/install/runtime-detect.mjs.map +1 -1
- package/dist/cli/install/toml-merge.d.mts +40 -0
- package/dist/cli/install/toml-merge.d.mts.map +1 -0
- package/dist/cli/install/toml-merge.mjs +134 -0
- package/dist/cli/install/toml-merge.mjs.map +1 -0
- package/dist/cli/personas/sidecar.d.mts +1 -1
- package/dist/cli/roadmap/lifecycle.d.mts +1 -1
- package/dist/cli/roadmap/rollup.d.mts +2 -2
- package/dist/cli/util/events-path.d.mts +8 -0
- package/dist/cli/util/events-path.d.mts.map +1 -0
- package/dist/cli/util/events-path.mjs +23 -0
- package/dist/cli/util/events-path.mjs.map +1 -0
- package/dist/cli/util/runtime-capabilities.d.mts +30 -0
- package/dist/cli/util/runtime-capabilities.d.mts.map +1 -0
- package/dist/cli/util/runtime-capabilities.mjs +214 -0
- package/dist/cli/util/runtime-capabilities.mjs.map +1 -0
- package/dist/cli/verify-proof/audit.d.mts +2 -7
- package/dist/cli/verify-proof/audit.d.mts.map +1 -1
- package/dist/cli/verify-proof/audit.mjs +11 -17
- package/dist/cli/verify-proof/audit.mjs.map +1 -1
- package/dist/cli/verify-proof/runtime-detect.d.mts +2 -29
- package/dist/cli/verify-proof/runtime-detect.d.mts.map +1 -1
- package/dist/cli/verify-proof/runtime-detect.mjs +8 -203
- package/dist/cli/verify-proof/runtime-detect.mjs.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +2 -1
- package/dist/plugin/agents/apt-verifier.md +6 -3
- package/dist/plugin/skills/apt/SKILL.md +29 -0
- package/dist/plugin/skills/apt-catch-up/SKILL.md +79 -0
- package/dist/plugin/skills/apt-pr-review/SKILL.md +59 -4
- package/dist/plugin/skills/apt-setup/SKILL.md +76 -0
- package/dist/plugin/skills/apt-verify/SKILL.md +1 -1
- package/dist/plugin/skills/apt-verify-proof/SKILL.md +25 -1
- package/drivers/browser/skill/agent-browser/SKILL.md +51 -0
- package/drivers/tauri/README.md +107 -0
- package/drivers/tauri/driver.mjs +88 -0
- package/drivers/tauri/manifest.json +37 -0
- package/drivers/tauri/scripts/tauri-mcp.sh +41 -0
- package/package.json +138 -138
- package/skills/apt/SKILL.md +29 -0
- package/skills/apt-catch-up/SKILL.md +79 -0
- package/skills/apt-pr-review/SKILL.md +59 -4
- package/skills/apt-setup/SKILL.md +76 -0
- package/skills/apt-verify/SKILL.md +1 -1
- package/skills/apt-verify-proof/SKILL.md +25 -1
- package/src/cli/commands/catch-up.mjs +67 -0
- package/src/cli/commands/init.mjs +19 -0
- package/src/cli/commands/pr-review.mjs +32 -11
- package/src/cli/commands/route.mjs +92 -1
- package/src/cli/config/share-policy.mjs +1 -0
- package/src/cli/config/upgrade-gitignore.mjs +1 -0
- package/src/cli/consistency/parse-review.mjs +5 -3
- package/src/cli/consistency/rules/r5-verdict-consistency.mjs +14 -9
- package/src/cli/coordination/catch-up.mjs +231 -0
- package/src/cli/coordination/last-seen.mjs +131 -0
- package/src/cli/coordination/store.mjs +18 -0
- package/src/cli/dispatch.mjs +2 -0
- package/src/cli/gate/gates/review-clean.mjs +4 -2
- package/src/cli/git/default-branch.mjs +250 -0
- package/src/cli/git/identity.mjs +9 -3
- package/src/cli/install/legacy-paths.mjs +2 -0
- package/src/cli/install/mcp-provision.mjs +293 -0
- package/src/cli/install/mcp-server-specs.mjs +101 -0
- package/src/cli/install/runtime-detect.mjs +9 -0
- package/src/cli/install/toml-merge.mjs +139 -0
- package/src/cli/util/events-path.mjs +24 -0
- package/src/cli/util/runtime-capabilities.mjs +216 -0
- package/src/cli/verify-proof/audit.mjs +11 -17
- package/src/cli/verify-proof/runtime-detect.mjs +11 -204
- package/templates/config.json +13 -1
|
@@ -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
|
-
|
|
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')
|
|
@@ -97,9 +97,11 @@ function normalizeStatus(f) {
|
|
|
97
97
|
if (STATUS_MAP[s]) return STATUS_MAP[s]
|
|
98
98
|
// BUG-009: reviewer-skill writes `deferred-per-spec`, `deferred-followup`,
|
|
99
99
|
// etc. for findings that are intentionally not being fixed in the
|
|
100
|
-
// current scope. Collapse the variant prefix back to canonical
|
|
101
|
-
//
|
|
102
|
-
//
|
|
100
|
+
// current scope. Collapse the variant prefix back to canonical `deferred`.
|
|
101
|
+
// Note: for blocking severities (critical/major), `deferred` no longer
|
|
102
|
+
// clears review-clean or R5 (G42 Phase 1). Only `status: "fixed"` clears
|
|
103
|
+
// those gates. Non-blocking severities (suggestion/note) are dropped by
|
|
104
|
+
// normalizeSeverity and never reach the blocking filter.
|
|
103
105
|
if (s.startsWith('deferred-')) return 'deferred'
|
|
104
106
|
}
|
|
105
107
|
return 'open'
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* consistency/rules/r5-verdict-consistency.mjs — R5 (C15).
|
|
4
4
|
*
|
|
5
5
|
* Fails when:
|
|
6
|
-
* - qa.verdict === 'approved' AND review has >=1
|
|
6
|
+
* - qa.verdict === 'approved' or 'approved-with-notes' AND review has >=1
|
|
7
|
+
* unresolved critical or major finding
|
|
7
8
|
* - qa.verdict === 'rejected' AND qa has no findings at all AND review has
|
|
8
9
|
* zero unresolved findings (rejected-without-evidence)
|
|
9
10
|
*
|
|
10
|
-
* `approved-with-notes`
|
|
11
|
-
*
|
|
11
|
+
* `approved-with-notes` no longer escapes unresolved critical or major
|
|
12
|
+
* findings (G42 Phase 1). It still passes for lower-severity issues.
|
|
12
13
|
*
|
|
13
14
|
* Severity: error. Stages: verify, ship.
|
|
14
15
|
*/
|
|
@@ -26,23 +27,27 @@ const rule = {
|
|
|
26
27
|
}
|
|
27
28
|
const verdict = parsed.qa.verdict ?? 'unknown'
|
|
28
29
|
const reviewFindings = parsed?.review?.findings ?? []
|
|
29
|
-
const
|
|
30
|
-
(f) => f.severity === 'critical' && f.status !== 'fixed',
|
|
30
|
+
const unresolvedBlocking = reviewFindings.filter(
|
|
31
|
+
(f) => (f.severity === 'critical' || f.severity === 'major') && f.status !== 'fixed',
|
|
31
32
|
)
|
|
32
|
-
if (
|
|
33
|
+
if (
|
|
34
|
+
(verdict === 'approved' || verdict === 'approved-with-notes') &&
|
|
35
|
+
unresolvedBlocking.length > 0
|
|
36
|
+
) {
|
|
33
37
|
return {
|
|
34
38
|
status: 'fail',
|
|
35
39
|
drift: [
|
|
36
40
|
{
|
|
37
41
|
verdict,
|
|
38
|
-
blockers:
|
|
42
|
+
blockers: unresolvedBlocking.map((f) => ({
|
|
39
43
|
id: f.id,
|
|
40
44
|
title: f.title,
|
|
41
45
|
})),
|
|
42
|
-
reason:
|
|
46
|
+
reason:
|
|
47
|
+
'approved/approved-with-notes verdict with unresolved critical or major finding(s) (G42 Phase 1)',
|
|
43
48
|
},
|
|
44
49
|
],
|
|
45
|
-
reason: `approved verdict contradicts ${
|
|
50
|
+
reason: `approved/approved-with-notes verdict contradicts ${unresolvedBlocking.length} unresolved critical/major finding(s)`,
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
if (verdict === 'rejected') {
|
|
@@ -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)
|
package/src/cli/dispatch.mjs
CHANGED
|
@@ -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)),
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* shapes the reviewer skill writes: `fixed: true`, `resolved: true`,
|
|
9
9
|
* `status: "fixed"` (string), and `status: "deferred-*"` variants
|
|
10
10
|
* (BUG-009). Fails if any post-normalization finding is severity
|
|
11
|
-
* critical|major AND status is
|
|
11
|
+
* critical|major AND status is not `fixed`. `deferred` no longer clears
|
|
12
|
+
* blocking findings (G42 Phase 1 — only `--force --reason` override may
|
|
13
|
+
* bypass blocking severity deferrals).
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import { existsSync, readFileSync } from 'node:fs'
|
|
@@ -55,7 +57,7 @@ const gate = {
|
|
|
55
57
|
// hole and picks up `deferred-*` variants for free.
|
|
56
58
|
const { findings: normalized } = parseReview(doc)
|
|
57
59
|
const unresolved = normalized.filter(
|
|
58
|
-
(f) => POST_NORM_BLOCKING.has(f.severity) && f.status !== 'fixed'
|
|
60
|
+
(f) => POST_NORM_BLOCKING.has(f.severity) && f.status !== 'fixed',
|
|
59
61
|
)
|
|
60
62
|
if (unresolved.length > 0) {
|
|
61
63
|
return {
|