@adhdev/mesh-shared 1.0.82-rc.4 → 1.0.82-rc.41
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/dist/assistant-protocol.d.ts +161 -6
- package/dist/decision-push-relay.d.ts +20 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1197 -118
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1107 -118
- package/dist/index.mjs.map +1 -1
- package/dist/launch-defaults.d.ts +196 -0
- package/dist/mesh-tool-names.d.ts +2 -2
- package/dist/mesh-vocabulary.d.ts +13 -4
- package/dist/mission-git-state.d.ts +83 -0
- package/dist/mission-items.d.ts +202 -0
- package/dist/node-facts.d.ts +75 -1
- package/dist/outbound-message.d.ts +9 -2
- package/dist/path-ownership.d.ts +1 -1
- package/dist/protocol/daemon-server.d.ts +42 -2
- package/dist/protocol/dashboard-daemon-p2p.d.ts +1 -1
- package/dist/protocol/dashboard-server.d.ts +20 -2
- package/dist/provider-approval.d.ts +36 -0
- package/dist/provider-question.d.ts +19 -0
- package/dist/quota-freshness.d.ts +65 -0
- package/dist/relay-reprobe.d.ts +52 -0
- package/dist/release.d.ts +90 -0
- package/dist/role-launch.d.ts +11 -0
- package/dist/session-launch.d.ts +10 -22
- package/dist/slot-proposal.d.ts +14 -0
- package/dist/task-source-phase.d.ts +75 -0
- package/dist/turn-evidence.d.ts +29 -13
- package/dist/turn-ipc-mission.d.ts +68 -1
- package/dist/turn-ipc-queue.d.ts +19 -0
- package/dist/turn-ipc.d.ts +2 -2
- package/dist/types.d.ts +1 -0
- package/dist/worker-protocol.d.ts +1 -1
- package/package.json +1 -1
- package/src/assistant-protocol.ts +302 -6
- package/src/daemon-normalize.ts +7 -5
- package/src/decision-push-relay.ts +23 -0
- package/src/index.ts +12 -0
- package/src/keyed-doc-delta.ts +9 -1
- package/src/launch-defaults.ts +170 -0
- package/src/mesh-tool-names.ts +16 -0
- package/src/mesh-vocabulary.ts +12 -2
- package/src/mission-git-state.ts +112 -0
- package/src/mission-items.ts +421 -0
- package/src/node-facts.ts +70 -1
- package/src/outbound-message.ts +19 -0
- package/src/path-ownership.ts +4 -6
- package/src/protocol/daemon-server.ts +37 -2
- package/src/protocol/dashboard-server.ts +13 -2
- package/src/provider-approval.ts +59 -0
- package/src/provider-question.ts +31 -0
- package/src/quota-freshness.ts +139 -0
- package/src/relay-reprobe.ts +117 -0
- package/src/release.ts +121 -0
- package/src/role-launch.ts +51 -0
- package/src/session-launch.ts +17 -1
- package/src/slot-proposal.ts +61 -2
- package/src/task-source-phase.ts +61 -0
- package/src/turn-evidence.ts +27 -20
- package/src/turn-ipc-mission.ts +121 -11
- package/src/turn-ipc-queue.ts +29 -1
- package/src/turn-ipc-turn.ts +1 -1
- package/src/turn-ipc.ts +1 -0
- package/src/types.ts +1 -0
- package/src/worker-protocol.ts +15 -0
package/src/mesh-tool-names.ts
CHANGED
|
@@ -27,7 +27,9 @@ export const CANONICAL_MESH_TOOL_NAMES = [
|
|
|
27
27
|
'mesh_enqueue_task',
|
|
28
28
|
'mesh_view_queue',
|
|
29
29
|
'mesh_queue_cancel',
|
|
30
|
+
'mesh_direct_cancel',
|
|
30
31
|
'mesh_queue_requeue',
|
|
32
|
+
'mesh_queue_revise',
|
|
31
33
|
'mesh_send_task',
|
|
32
34
|
'mesh_read_chat',
|
|
33
35
|
'mesh_read_debug',
|
|
@@ -44,8 +46,11 @@ export const CANONICAL_MESH_TOOL_NAMES = [
|
|
|
44
46
|
'mesh_list_pending_approvals',
|
|
45
47
|
'mesh_create',
|
|
46
48
|
'mesh_add_node',
|
|
49
|
+
'mesh_reattach_source_parent',
|
|
50
|
+
'mesh_attach_remote',
|
|
47
51
|
'mesh_clone_node',
|
|
48
52
|
'mesh_remove_node',
|
|
53
|
+
'mesh_retry_node_bootstrap',
|
|
49
54
|
'mesh_cleanup_worktree_nodes',
|
|
50
55
|
'mesh_refine_node',
|
|
51
56
|
'mesh_refine_batch',
|
|
@@ -62,8 +67,19 @@ export const CANONICAL_MESH_TOOL_NAMES = [
|
|
|
62
67
|
'mesh_reconcile_ledger',
|
|
63
68
|
'mesh_mission_upsert',
|
|
64
69
|
'mesh_mission_list',
|
|
70
|
+
'mesh_mission_git_eval',
|
|
71
|
+
'mesh_release_upsert',
|
|
72
|
+
'mesh_release_scope',
|
|
73
|
+
'mesh_release_list',
|
|
74
|
+
'mesh_release_record_deploy',
|
|
75
|
+
'mesh_release_changelog_draft',
|
|
65
76
|
'mesh_review_inbox',
|
|
66
77
|
'mesh_node_slots',
|
|
78
|
+
'driver_list',
|
|
79
|
+
'driver_describe',
|
|
80
|
+
'artifact_list',
|
|
81
|
+
'artifact_read',
|
|
82
|
+
'artifact_register',
|
|
67
83
|
] as const;
|
|
68
84
|
|
|
69
85
|
export type CanonicalMeshToolName = typeof CANONICAL_MESH_TOOL_NAMES[number];
|
package/src/mesh-vocabulary.ts
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
export { MESH_TASK_DIFFICULTIES, type MeshTaskDifficulty } from './brain-routing'
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/** `blocked` ends a worker attempt pending external follow-up; only completed satisfies dependencies.
|
|
21
|
+
* Branch convergence (including blocked_review) remains an independent axis. */
|
|
22
|
+
export const MESH_TASK_STATUSES = ['pending', 'assigned', 'completed', 'blocked', 'failed', 'cancelled'] as const
|
|
21
23
|
export type MeshTaskStatus = typeof MESH_TASK_STATUSES[number]
|
|
22
24
|
|
|
23
|
-
export const MESH_TERMINAL_TASK_STATUSES = ['completed', 'failed', 'cancelled'] as const
|
|
25
|
+
export const MESH_TERMINAL_TASK_STATUSES = ['completed', 'blocked', 'failed', 'cancelled'] as const
|
|
24
26
|
export type MeshTerminalTaskStatus = typeof MESH_TERMINAL_TASK_STATUSES[number]
|
|
25
27
|
|
|
26
28
|
export const MESH_TASK_MODES = ['code_change', 'validation', 'live_debug_readonly', 'launch_app', 'convergence'] as const
|
|
@@ -56,6 +58,14 @@ export type MeshSessionCleanupMode = typeof MESH_SESSION_CLEANUP_MODES[number]
|
|
|
56
58
|
export const WORKER_REPORT_OUTCOMES = ['completed', 'blocked', 'failed'] as const
|
|
57
59
|
export type WorkerReportOutcome = typeof WORKER_REPORT_OUTCOMES[number]
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Hard cap on a `report_completion` summary (characters, after trim). Over-limit
|
|
63
|
+
* summaries are REJECTED, never truncated. Declared once so the daemon validator
|
|
64
|
+
* and the mcp-server's pre-send check (which keeps an over-limit report out of the
|
|
65
|
+
* durable outbox) cannot drift apart.
|
|
66
|
+
*/
|
|
67
|
+
export const WORKER_REPORT_SUMMARY_MAX_CHARS = 8_000
|
|
68
|
+
|
|
59
69
|
/** Final branch state a worker reports (see coordinator convergence rules). */
|
|
60
70
|
export const WORKER_BRANCH_STATES = [
|
|
61
71
|
'merged_to_main',
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mission-git-state — types and the pure aggregation for "did this mission's
|
|
3
|
+
* referenced commits/branches land on the default branch?"
|
|
4
|
+
* (docs/design/2026-11-01-mission-git-refs.md §3).
|
|
5
|
+
*
|
|
6
|
+
* The facts come from the daemon opening git objects itself (never from
|
|
7
|
+
* commit-message text, branch-name similarity or worker prose). `landed` means
|
|
8
|
+
* ONLY "the referenced changes are on the base"; it never implies live
|
|
9
|
+
* acceptance, mission completion or that remaining items are done, and it is
|
|
10
|
+
* never used to change a mission's status.
|
|
11
|
+
*
|
|
12
|
+
* Dependency-free leaf: plain data in, plain data out.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Per-ref verdict. `unknown` is never folded into `not_contained` nor promoted to `contained_*`. */
|
|
16
|
+
export const MISSION_REF_GIT_STATUSES = ['contained_ancestor', 'contained_patch_equivalent', 'not_contained', 'unknown'] as const
|
|
17
|
+
export type MissionRefGitStatus = typeof MISSION_REF_GIT_STATUSES[number]
|
|
18
|
+
|
|
19
|
+
/** How a contained verdict was reached. */
|
|
20
|
+
export const MISSION_REF_GIT_METHODS = ['ancestry', 'cherry', 'residual_patch'] as const
|
|
21
|
+
export type MissionRefGitMethod = typeof MISSION_REF_GIT_METHODS[number]
|
|
22
|
+
|
|
23
|
+
/** Mission-level projection. */
|
|
24
|
+
export const MISSION_GIT_STATES = ['no_refs', 'landed', 'partial', 'not_landed', 'unknown'] as const
|
|
25
|
+
export type MissionGitStateValue = typeof MISSION_GIT_STATES[number]
|
|
26
|
+
|
|
27
|
+
export function isMissionRefContained(status: MissionRefGitStatus): boolean {
|
|
28
|
+
return status === 'contained_ancestor' || status === 'contained_patch_equivalent'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Why a ref is `unknown` (detail.unknownReason). Free-form strings are tolerated on read. */
|
|
32
|
+
export type MissionRefUnknownReason =
|
|
33
|
+
| 'object_missing'
|
|
34
|
+
| 'shallow'
|
|
35
|
+
| 'undeterminable'
|
|
36
|
+
| 'base_missing'
|
|
37
|
+
| 'branch_not_found'
|
|
38
|
+
| 'base_branch_ref'
|
|
39
|
+
| 'gitlink_undeterminable'
|
|
40
|
+
| 'merge_tree_failed'
|
|
41
|
+
| 'node_offline'
|
|
42
|
+
| 'no_repo'
|
|
43
|
+
| 'budget'
|
|
44
|
+
| 'not_a_git_ref'
|
|
45
|
+
|
|
46
|
+
export interface MissionRefGitDetail {
|
|
47
|
+
reasonCode?: string
|
|
48
|
+
unknownReason?: MissionRefUnknownReason | string
|
|
49
|
+
/** `modified_since_landing_possible`: main later touched the same lines — shown as a hint, not a verdict. */
|
|
50
|
+
note?: string
|
|
51
|
+
nodeId?: string
|
|
52
|
+
residualPatchId?: string
|
|
53
|
+
ahead?: number
|
|
54
|
+
behind?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One stored per-ref evaluation (a row of `mesh_mission_ref_evals`). */
|
|
58
|
+
export interface MissionRefGitEval {
|
|
59
|
+
itemId: string
|
|
60
|
+
/** `kind:repo:value` (normalized, lower-case SHA). */
|
|
61
|
+
refKey: string
|
|
62
|
+
kind: 'commit' | 'branch'
|
|
63
|
+
value: string
|
|
64
|
+
repo?: string
|
|
65
|
+
/** Branch refs: the SHA the name resolved to at first evaluation (re-evaluation keeps using it). */
|
|
66
|
+
pinnedSha?: string
|
|
67
|
+
baseRef: string
|
|
68
|
+
baseSha?: string
|
|
69
|
+
status: MissionRefGitStatus
|
|
70
|
+
method?: MissionRefGitMethod
|
|
71
|
+
detail?: MissionRefGitDetail
|
|
72
|
+
evaluatedAt: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface MissionGitState {
|
|
76
|
+
missionId: string
|
|
77
|
+
state: MissionGitStateValue
|
|
78
|
+
/** `partial` only: some refs were `unknown` (not just `not_contained`). */
|
|
79
|
+
partialUnknown: boolean
|
|
80
|
+
counts: { total: number; contained: number; notContained: number; unknown: number }
|
|
81
|
+
/** Base the newest evaluation compared against. */
|
|
82
|
+
baseRef?: string
|
|
83
|
+
baseSha?: string
|
|
84
|
+
/** Oldest `evaluatedAt` among the refs (the freshness floor). */
|
|
85
|
+
evaluatedAt?: string
|
|
86
|
+
/** True when no row exists yet / the rows are older than the TTL — a refresh was (or can be) triggered. */
|
|
87
|
+
stale: boolean
|
|
88
|
+
refs: MissionRefGitEval[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Aggregate per-ref statuses (design §3.3):
|
|
93
|
+
* - none → `no_refs`; all contained → `landed`; some contained → `partial`
|
|
94
|
+
* (`partialUnknown` when any of the rest is `unknown`);
|
|
95
|
+
* - none contained: all `not_contained` → `not_landed`, otherwise `unknown`.
|
|
96
|
+
*/
|
|
97
|
+
export function aggregateMissionGitState(statuses: readonly MissionRefGitStatus[]): {
|
|
98
|
+
state: MissionGitStateValue
|
|
99
|
+
partialUnknown: boolean
|
|
100
|
+
counts: MissionGitState['counts']
|
|
101
|
+
} {
|
|
102
|
+
const total = statuses.length
|
|
103
|
+
const contained = statuses.filter(isMissionRefContained).length
|
|
104
|
+
const notContained = statuses.filter(s => s === 'not_contained').length
|
|
105
|
+
const unknown = statuses.filter(s => s === 'unknown').length
|
|
106
|
+
const counts = { total, contained, notContained, unknown }
|
|
107
|
+
if (total === 0) return { state: 'no_refs', partialUnknown: false, counts }
|
|
108
|
+
if (contained === total) return { state: 'landed', partialUnknown: false, counts }
|
|
109
|
+
if (contained > 0) return { state: 'partial', partialUnknown: unknown > 0, counts }
|
|
110
|
+
if (notContained === total) return { state: 'not_landed', partialUnknown: false, counts }
|
|
111
|
+
return { state: 'unknown', partialUnknown: false, counts }
|
|
112
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mission-items — structured mission records: the item kinds a mission carries
|
|
3
|
+
* (remaining work, live checks, owner decisions, evidence), their normalization
|
|
4
|
+
* and limits, dedupe-key derivation and the close outcome enum
|
|
5
|
+
* (docs/design/2026-10-28-structured-missions-and-owner-decisions.md §4, §5).
|
|
6
|
+
*
|
|
7
|
+
* Philosophy (owner 2026-10-28, design §0): NEVER reject for size or shape —
|
|
8
|
+
* over-long text is truncated, extra refs are dropped, unusable items are
|
|
9
|
+
* reported per-item. The only hard rule lives elsewhere (the daemon): a
|
|
10
|
+
* coordinator or worker can never ANSWER a decision, so `answer`/`delivery`
|
|
11
|
+
* are stripped from every caller-supplied `decision` here.
|
|
12
|
+
*
|
|
13
|
+
* Dependency-free leaf: pure functions over plain data, no storage.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const MISSION_ITEM_KINDS = ['remaining', 'live_check', 'decision', 'evidence'] as const
|
|
17
|
+
export type MissionItemKind = typeof MISSION_ITEM_KINDS[number]
|
|
18
|
+
|
|
19
|
+
export const MISSION_ITEM_STATES = ['open', 'done', 'failed', 'dropped', 'moved', 'answered', 'expired', 'withdrawn', 'superseded'] as const
|
|
20
|
+
export type MissionItemState = typeof MISSION_ITEM_STATES[number]
|
|
21
|
+
|
|
22
|
+
/** States a coordinator/worker may SET through items_update, per kind. `answered`/`expired`/`failed` (owner live-check verdict) are daemon-owned. */
|
|
23
|
+
export const COORDINATOR_SETTABLE_ITEM_STATES: Readonly<Record<MissionItemKind, readonly MissionItemState[]>> = {
|
|
24
|
+
remaining: ['open', 'done', 'dropped'],
|
|
25
|
+
live_check: ['open', 'done', 'dropped'],
|
|
26
|
+
evidence: ['open', 'done', 'dropped'],
|
|
27
|
+
decision: ['open', 'withdrawn', 'superseded'],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const MISSION_OUTCOMES = ['merged', 'verified_live', 'superseded', 'not_needed', 'owner_abandoned', 'unspecified'] as const
|
|
31
|
+
export type MissionOutcome = typeof MISSION_OUTCOMES[number]
|
|
32
|
+
export function isMissionOutcome(value: unknown): value is MissionOutcome {
|
|
33
|
+
return typeof value === 'string' && (MISSION_OUTCOMES as readonly string[]).includes(value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const MISSION_REF_KINDS = ['commit', 'branch', 'pr', 'task', 'artifact', 'mission', 'url'] as const
|
|
37
|
+
export type MissionRefKind = typeof MISSION_REF_KINDS[number]
|
|
38
|
+
|
|
39
|
+
export const MISSION_LIMITS = {
|
|
40
|
+
summary: 300,
|
|
41
|
+
outcomeReason: 500,
|
|
42
|
+
itemText: 300,
|
|
43
|
+
machine: 80,
|
|
44
|
+
refs: 8,
|
|
45
|
+
refValue: 300,
|
|
46
|
+
refRepo: 120,
|
|
47
|
+
refNote: 200,
|
|
48
|
+
dedupeKey: 400,
|
|
49
|
+
question: 300,
|
|
50
|
+
context: 600,
|
|
51
|
+
options: 5,
|
|
52
|
+
optionLabel: 60,
|
|
53
|
+
optionDetail: 200,
|
|
54
|
+
/** Open items per mission before the oldest `remaining` fold into a "N more" summary item. */
|
|
55
|
+
openItemsPerMission: 200,
|
|
56
|
+
/** One event payload (serialized). */
|
|
57
|
+
eventPayloadBytes: 16 * 1024,
|
|
58
|
+
/** items_add / items_update entries honoured per call (extra entries are reported, not applied). */
|
|
59
|
+
itemsPerCall: 50,
|
|
60
|
+
} as const
|
|
61
|
+
|
|
62
|
+
export const MISSION_DECISION_OPTION_IDS = ['a', 'b', 'c', 'd', 'e'] as const
|
|
63
|
+
export const MISSION_DECISION_URGENCIES = ['normal', 'blocking'] as const
|
|
64
|
+
export type MissionDecisionUrgency = typeof MISSION_DECISION_URGENCIES[number]
|
|
65
|
+
export const MISSION_DECISION_ON_DUE = ['remind', 'apply_recommended'] as const
|
|
66
|
+
export type MissionDecisionOnDue = typeof MISSION_DECISION_ON_DUE[number]
|
|
67
|
+
|
|
68
|
+
export interface MissionItemRef {
|
|
69
|
+
kind: MissionRefKind
|
|
70
|
+
repo?: string
|
|
71
|
+
value: string
|
|
72
|
+
note?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface MissionDecisionOption {
|
|
76
|
+
id: string
|
|
77
|
+
label: string
|
|
78
|
+
detail?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Who/where an answer came from. Written only by the daemon's (owner-side) answer path — never from tool input. */
|
|
82
|
+
export interface MissionDecisionAnswer {
|
|
83
|
+
optionId?: string
|
|
84
|
+
text?: string
|
|
85
|
+
by: 'owner' | 'auto_recommended'
|
|
86
|
+
via: 'dashboard' | 'assistant' | 'push' | 'cli' | 'auto'
|
|
87
|
+
interpretedFrom?: string
|
|
88
|
+
at: string
|
|
89
|
+
ingress?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface MissionDecisionDelivery {
|
|
93
|
+
messageId: string
|
|
94
|
+
/**
|
|
95
|
+
* `queued`: the coordinator's input queue accepted it but has not typed it yet — the daemon
|
|
96
|
+
* confirms it against the send receipt (a daemon restart drops that queue). Daemon-written.
|
|
97
|
+
*/
|
|
98
|
+
state: 'pending' | 'queued' | 'sent' | 'acked'
|
|
99
|
+
lastAttemptAt?: string
|
|
100
|
+
/** Coordinator session + funnel messageId of a `queued` delivery (receipt lookup). Daemon-written. */
|
|
101
|
+
sessionId?: string
|
|
102
|
+
wireMessageId?: string
|
|
103
|
+
/** Failed send attempts of this revision (retry backoff). Daemon-written. */
|
|
104
|
+
attempts?: number
|
|
105
|
+
/** What the coordinator is told: a first answer, a changed answer, or an undone one. Daemon-written. */
|
|
106
|
+
kind?: 'answered' | 'changed' | 'undone'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface MissionDecision {
|
|
110
|
+
question: string
|
|
111
|
+
context?: string
|
|
112
|
+
options: MissionDecisionOption[]
|
|
113
|
+
recommended?: string
|
|
114
|
+
allowFreeText: boolean
|
|
115
|
+
dueAt?: string
|
|
116
|
+
onDue: MissionDecisionOnDue
|
|
117
|
+
urgency: MissionDecisionUrgency
|
|
118
|
+
answer?: MissionDecisionAnswer
|
|
119
|
+
delivery?: MissionDecisionDelivery
|
|
120
|
+
/**
|
|
121
|
+
* Coordinator-prompt watermark (bundle B): ISO time the coordinator last "saw" this item's
|
|
122
|
+
* answer. An answered decision is shown in the coordinator prompt while this is absent or
|
|
123
|
+
* older than `answer.at` (a changed answer re-surfaces). Written only by the daemon.
|
|
124
|
+
*/
|
|
125
|
+
promptAckedAt?: string
|
|
126
|
+
/** Counts answer writes (answer / change / undo); delivery messageIds are `decision:<itemId>:r<n>`. Daemon-written. */
|
|
127
|
+
answerRevision?: number
|
|
128
|
+
/** ISO time an `onDue: remind` crossing was recorded (once per dueAt). Daemon-written. */
|
|
129
|
+
remindedAt?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface NormalizedMissionItem {
|
|
133
|
+
kind: MissionItemKind
|
|
134
|
+
text: string
|
|
135
|
+
machine?: string
|
|
136
|
+
refs: MissionItemRef[]
|
|
137
|
+
decision?: MissionDecision
|
|
138
|
+
dedupeKey: string
|
|
139
|
+
/** Fields that were truncated/dropped — informational, never an error. */
|
|
140
|
+
truncated: string[]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function truncateMissionText(value: string, max: number): string {
|
|
144
|
+
if (value.length <= max) return value
|
|
145
|
+
return `${value.slice(0, max - 1).trimEnd()}…`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function str(value: unknown): string {
|
|
149
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function collapse(value: string): string {
|
|
153
|
+
return value.replace(/\s+/g, ' ').trim()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function normalizeMissionSummary(value: unknown): { value: string; truncated: boolean } | null {
|
|
157
|
+
if (typeof value !== 'string') return null
|
|
158
|
+
const text = collapse(value)
|
|
159
|
+
return { value: truncateMissionText(text, MISSION_LIMITS.summary), truncated: text.length > MISSION_LIMITS.summary }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function normalizeOutcomeReason(value: unknown): { value: string; truncated: boolean } | null {
|
|
163
|
+
if (typeof value !== 'string') return null
|
|
164
|
+
const text = value.trim()
|
|
165
|
+
return { value: truncateMissionText(text, MISSION_LIMITS.outcomeReason), truncated: text.length > MISSION_LIMITS.outcomeReason }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Refs: ≤8, `{kind, repo?, value, note?}`. Unusable entries are dropped; the 9th and later are dropped. */
|
|
169
|
+
export function normalizeMissionRefs(value: unknown): { refs: MissionItemRef[]; truncated: boolean } {
|
|
170
|
+
if (!Array.isArray(value)) return { refs: [], truncated: false }
|
|
171
|
+
const refs: MissionItemRef[] = []
|
|
172
|
+
let truncated = false
|
|
173
|
+
for (const raw of value) {
|
|
174
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
|
175
|
+
const r = raw as Record<string, unknown>
|
|
176
|
+
const kind = str(r.kind)
|
|
177
|
+
const refValue = str(r.value)
|
|
178
|
+
if (!(MISSION_REF_KINDS as readonly string[]).includes(kind) || !refValue) continue
|
|
179
|
+
if (refs.length >= MISSION_LIMITS.refs) { truncated = true; break }
|
|
180
|
+
const repo = str(r.repo)
|
|
181
|
+
const note = str(r.note)
|
|
182
|
+
refs.push({
|
|
183
|
+
kind: kind as MissionRefKind,
|
|
184
|
+
...(repo ? { repo: truncateMissionText(repo, MISSION_LIMITS.refRepo) } : {}),
|
|
185
|
+
value: truncateMissionText(refValue, MISSION_LIMITS.refValue),
|
|
186
|
+
...(note ? { note: truncateMissionText(note, MISSION_LIMITS.refNote) } : {}),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
return { refs, truncated }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeOptions(value: unknown, truncated: string[]): MissionDecisionOption[] {
|
|
193
|
+
if (!Array.isArray(value)) return []
|
|
194
|
+
const out: MissionDecisionOption[] = []
|
|
195
|
+
for (const raw of value) {
|
|
196
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
197
|
+
if (out.length >= MISSION_LIMITS.options) { truncated.push('options'); break }
|
|
198
|
+
out.push({ id: MISSION_DECISION_OPTION_IDS[out.length], label: truncateMissionText(raw.trim(), MISSION_LIMITS.optionLabel) })
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
|
202
|
+
const o = raw as Record<string, unknown>
|
|
203
|
+
const label = collapse(str(o.label))
|
|
204
|
+
if (!label) continue
|
|
205
|
+
if (out.length >= MISSION_LIMITS.options) { truncated.push('options'); break }
|
|
206
|
+
const detail = str(o.detail)
|
|
207
|
+
// Ids are positional ('a'..'e') so a caller cannot collide or invent ids.
|
|
208
|
+
out.push({
|
|
209
|
+
id: MISSION_DECISION_OPTION_IDS[out.length],
|
|
210
|
+
label: truncateMissionText(label, MISSION_LIMITS.optionLabel),
|
|
211
|
+
...(detail ? { detail: truncateMissionText(detail, MISSION_LIMITS.optionDetail) } : {}),
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeDueAt(value: unknown): string | undefined {
|
|
218
|
+
if (typeof value !== 'string' || !value.trim()) return undefined
|
|
219
|
+
const ms = Date.parse(value)
|
|
220
|
+
return Number.isNaN(ms) ? undefined : new Date(ms).toISOString()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Caller-supplied decision → stored shape. `answer`/`delivery` (and any other
|
|
225
|
+
* unknown key) are DROPPED: a coordinator/worker cannot pre-answer its own
|
|
226
|
+
* question. `recommended` must name one of the options, else it is dropped.
|
|
227
|
+
*/
|
|
228
|
+
export function normalizeDecisionInput(raw: Record<string, unknown>, truncated: string[]): MissionDecision | null {
|
|
229
|
+
const questionSource = collapse(str(raw.question) || str(raw.text))
|
|
230
|
+
if (!questionSource) return null
|
|
231
|
+
if (questionSource.length > MISSION_LIMITS.question) truncated.push('question')
|
|
232
|
+
const question = truncateMissionText(questionSource, MISSION_LIMITS.question)
|
|
233
|
+
const contextSource = str(raw.context)
|
|
234
|
+
if (contextSource.length > MISSION_LIMITS.context) truncated.push('context')
|
|
235
|
+
const options = normalizeOptions(raw.options, truncated)
|
|
236
|
+
const recommendedRaw = str(raw.recommended)
|
|
237
|
+
const recommended = recommendedRaw && options.some(o => o.id === recommendedRaw)
|
|
238
|
+
? recommendedRaw
|
|
239
|
+
// Also accept the option label (coordinators naturally write "recommended: Option A").
|
|
240
|
+
: options.find(o => recommendedRaw && o.label.toLowerCase() === recommendedRaw.toLowerCase())?.id
|
|
241
|
+
const dueAt = normalizeDueAt(raw.dueAt ?? raw.due_at)
|
|
242
|
+
const onDueRaw = str(raw.onDue ?? raw.on_due)
|
|
243
|
+
const urgencyRaw = str(raw.urgency)
|
|
244
|
+
const allowFreeTextRaw = raw.allowFreeText ?? raw.allow_free_text
|
|
245
|
+
return {
|
|
246
|
+
question,
|
|
247
|
+
...(contextSource ? { context: truncateMissionText(contextSource, MISSION_LIMITS.context) } : {}),
|
|
248
|
+
options,
|
|
249
|
+
...(recommended ? { recommended } : {}),
|
|
250
|
+
allowFreeText: typeof allowFreeTextRaw === 'boolean' ? allowFreeTextRaw : true,
|
|
251
|
+
...(dueAt ? { dueAt } : {}),
|
|
252
|
+
// apply_recommended without a recommended option has nothing to apply — fall back to remind.
|
|
253
|
+
onDue: onDueRaw === 'apply_recommended' && recommended ? 'apply_recommended' : 'remind',
|
|
254
|
+
urgency: urgencyRaw === 'blocking' ? 'blocking' : 'normal',
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Normalized text for dedupe: lower-case, whitespace-collapsed, trailing punctuation trimmed. */
|
|
259
|
+
export function normalizeForDedupe(text: string): string {
|
|
260
|
+
return collapse(text).toLowerCase().replace(/[\s.!?。]+$/u, '')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function deriveItemDedupeKey(kind: MissionItemKind, parts: { text?: string; machine?: string; refs?: readonly MissionItemRef[]; question?: string; explicit?: string }): string {
|
|
264
|
+
const explicit = str(parts.explicit)
|
|
265
|
+
if (explicit) return truncateMissionText(`k:${explicit}`, MISSION_LIMITS.dedupeKey)
|
|
266
|
+
if (kind === 'decision') return truncateMissionText(`decision:${normalizeForDedupe(parts.question ?? parts.text ?? '')}`, MISSION_LIMITS.dedupeKey)
|
|
267
|
+
if (kind === 'evidence') {
|
|
268
|
+
const ref = parts.refs?.[0]
|
|
269
|
+
if (ref) return truncateMissionText(`evidence:${ref.kind}:${ref.repo ?? ''}:${ref.value}`, MISSION_LIMITS.dedupeKey)
|
|
270
|
+
}
|
|
271
|
+
const machine = parts.machine ? `@${normalizeForDedupe(parts.machine)}` : ''
|
|
272
|
+
return truncateMissionText(`${kind}${machine}:${normalizeForDedupe(parts.text ?? '')}`, MISSION_LIMITS.dedupeKey)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Normalize one caller-supplied `items_add[]` entry. Returns `null` + `error`
|
|
277
|
+
* only when nothing usable remains (no text/question) — everything else is
|
|
278
|
+
* clamped, never rejected.
|
|
279
|
+
*/
|
|
280
|
+
export function normalizeMissionItemInput(raw: unknown): { item: NormalizedMissionItem } | { error: string } {
|
|
281
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'item must be an object' }
|
|
282
|
+
const r = raw as Record<string, unknown>
|
|
283
|
+
const kind = str(r.kind) || 'remaining'
|
|
284
|
+
if (!(MISSION_ITEM_KINDS as readonly string[]).includes(kind)) {
|
|
285
|
+
return { error: `invalid kind '${kind}' (valid: ${MISSION_ITEM_KINDS.join(', ')})` }
|
|
286
|
+
}
|
|
287
|
+
const truncated: string[] = []
|
|
288
|
+
const { refs, truncated: refsTruncated } = normalizeMissionRefs(r.refs)
|
|
289
|
+
if (refsTruncated) truncated.push('refs')
|
|
290
|
+
const machineRaw = collapse(str(r.machine))
|
|
291
|
+
const machine = machineRaw ? truncateMissionText(machineRaw, MISSION_LIMITS.machine) : undefined
|
|
292
|
+
const explicitKey = str(r.dedupe_key ?? r.dedupeKey)
|
|
293
|
+
if (kind === 'decision') {
|
|
294
|
+
const decisionRaw = (r.decision && typeof r.decision === 'object' && !Array.isArray(r.decision) ? r.decision : r) as Record<string, unknown>
|
|
295
|
+
const decision = normalizeDecisionInput({ ...decisionRaw, question: decisionRaw.question ?? r.question ?? r.text }, truncated)
|
|
296
|
+
if (!decision) return { error: 'decision item needs a question' }
|
|
297
|
+
return {
|
|
298
|
+
item: {
|
|
299
|
+
kind,
|
|
300
|
+
text: decision.question,
|
|
301
|
+
...(machine ? { machine } : {}),
|
|
302
|
+
refs,
|
|
303
|
+
decision,
|
|
304
|
+
dedupeKey: deriveItemDedupeKey('decision', { question: decision.question, explicit: explicitKey }),
|
|
305
|
+
truncated,
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const textSource = collapse(str(r.text))
|
|
310
|
+
if (!textSource && !(kind === 'evidence' && refs.length > 0)) return { error: `${kind} item needs text` }
|
|
311
|
+
if (textSource.length > MISSION_LIMITS.itemText) truncated.push('text')
|
|
312
|
+
const text = truncateMissionText(textSource || `${refs[0].kind}: ${refs[0].value}`, MISSION_LIMITS.itemText)
|
|
313
|
+
return {
|
|
314
|
+
item: {
|
|
315
|
+
kind: kind as MissionItemKind,
|
|
316
|
+
text,
|
|
317
|
+
...(machine ? { machine } : {}),
|
|
318
|
+
refs,
|
|
319
|
+
dedupeKey: deriveItemDedupeKey(kind as MissionItemKind, { text, machine, refs, explicit: explicitKey }),
|
|
320
|
+
truncated,
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface MissionItemUpdateInput {
|
|
326
|
+
id: string
|
|
327
|
+
state?: MissionItemState
|
|
328
|
+
text?: string
|
|
329
|
+
machine?: string | null
|
|
330
|
+
refs?: MissionItemRef[]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Normalize one `items_update[]` entry (id required). The kind-specific state rule is applied by the daemon, which knows the stored kind. */
|
|
334
|
+
export function normalizeMissionItemUpdate(raw: unknown): { update: MissionItemUpdateInput; truncated: string[] } | { error: string } {
|
|
335
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'update must be an object' }
|
|
336
|
+
const r = raw as Record<string, unknown>
|
|
337
|
+
const id = str(r.id ?? r.item_id ?? r.itemId)
|
|
338
|
+
if (!id) return { error: 'update needs an item id' }
|
|
339
|
+
const truncated: string[] = []
|
|
340
|
+
const update: MissionItemUpdateInput = { id }
|
|
341
|
+
if (r.state !== undefined) {
|
|
342
|
+
const state = str(r.state)
|
|
343
|
+
if (!(MISSION_ITEM_STATES as readonly string[]).includes(state)) return { error: `invalid state '${state}'` }
|
|
344
|
+
update.state = state as MissionItemState
|
|
345
|
+
}
|
|
346
|
+
if (typeof r.text === 'string') {
|
|
347
|
+
const text = collapse(r.text)
|
|
348
|
+
if (text.length > MISSION_LIMITS.itemText) truncated.push('text')
|
|
349
|
+
if (text) update.text = truncateMissionText(text, MISSION_LIMITS.itemText)
|
|
350
|
+
}
|
|
351
|
+
if (r.machine === null) update.machine = null
|
|
352
|
+
else if (typeof r.machine === 'string') {
|
|
353
|
+
const machine = collapse(r.machine)
|
|
354
|
+
update.machine = machine ? truncateMissionText(machine, MISSION_LIMITS.machine) : null
|
|
355
|
+
}
|
|
356
|
+
if (r.refs !== undefined) {
|
|
357
|
+
const { refs, truncated: t } = normalizeMissionRefs(r.refs)
|
|
358
|
+
if (t) truncated.push('refs')
|
|
359
|
+
update.refs = refs
|
|
360
|
+
}
|
|
361
|
+
return { update, truncated }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Cap a serialized event payload at {@link MISSION_LIMITS.eventPayloadBytes}; over-size payloads become a stub that says so. */
|
|
365
|
+
export function boundEventPayload(payload: Record<string, unknown>): string {
|
|
366
|
+
let json = JSON.stringify(payload)
|
|
367
|
+
if (json.length <= MISSION_LIMITS.eventPayloadBytes) return json
|
|
368
|
+
// Shrink the largest string fields first (the previous goal text is the usual offender).
|
|
369
|
+
const shrunk: Record<string, unknown> = {}
|
|
370
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
371
|
+
shrunk[k] = typeof v === 'string' && v.length > 4000 ? `${v.slice(0, 4000)}…` : v
|
|
372
|
+
}
|
|
373
|
+
shrunk.payloadTruncated = true
|
|
374
|
+
json = JSON.stringify(shrunk)
|
|
375
|
+
if (json.length <= MISSION_LIMITS.eventPayloadBytes) return json
|
|
376
|
+
return JSON.stringify({ payloadTruncated: true, keys: Object.keys(payload).slice(0, 20) })
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The carry-over mission's fixed title (design §5.1). */
|
|
380
|
+
export const CARRY_OVER_MISSION_TITLE = 'Carry-over — open items from closed missions'
|
|
381
|
+
/** The title earlier builds stored; existing DBs still hold it, so it keeps matching (no rename). */
|
|
382
|
+
export const LEGACY_CARRY_OVER_MISSION_TITLE = '이월 — 닫힌 미션에서 넘어온 항목'
|
|
383
|
+
/** Canonical goal text of a newly created carry-over mission. */
|
|
384
|
+
export const CARRY_OVER_MISSION_GOAL = 'Open items carried over from closed missions, and decisions requested without a mission. Managed automatically — do not close.'
|
|
385
|
+
/**
|
|
386
|
+
* THE one predicate for "is this the auto carry-over mission". The daemon's `is_carry_over`
|
|
387
|
+
* column (`isCarryOver`) is the source of truth; wire views that cannot carry the flag (older
|
|
388
|
+
* strict decoders reject unknown keys) fall back to the fixed title. Use this everywhere.
|
|
389
|
+
*/
|
|
390
|
+
export function isCarryOverMissionRecord(m: { isCarryOver?: boolean; title?: unknown } | null | undefined): boolean {
|
|
391
|
+
return !!m && (m.isCarryOver === true || isCarryOverMissionTitle(m.title))
|
|
392
|
+
}
|
|
393
|
+
/** The carry-over mission's title is fixed (the daemon ignores renames), so a title match identifies it on wire views that carry no flag. */
|
|
394
|
+
export function isCarryOverMissionTitle(title: unknown): boolean {
|
|
395
|
+
if (typeof title !== 'string') return false
|
|
396
|
+
const t = title.trim()
|
|
397
|
+
return t === CARRY_OVER_MISSION_TITLE || t === LEGACY_CARRY_OVER_MISSION_TITLE
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Prefix of the owner-confirmation decision a coordinator's `owner_abandoned` close creates. */
|
|
401
|
+
export const OWNER_ABANDON_CONFIRM_KEY_PREFIX = 'owner-abandon-confirm:'
|
|
402
|
+
|
|
403
|
+
export const OWNER_ABANDON_CONFIRM_QUESTION = "The coordinator closed this as 'owner abandoned' — correct?"
|
|
404
|
+
export const OWNER_ABANDON_CONFIRM_OPTIONS = ['Correct', 'Reopen'] as const
|
|
405
|
+
|
|
406
|
+
/** worker `report_completion.decisions_needed` cap. */
|
|
407
|
+
export const WORKER_DECISIONS_NEEDED_MAX = 3
|
|
408
|
+
/** worker `report_completion.commits` cap and shape. */
|
|
409
|
+
export const WORKER_COMMITS_MAX = 20
|
|
410
|
+
const SHA_RE = /^[0-9a-f]{7,64}$/i
|
|
411
|
+
export function normalizeReportCommits(value: unknown): string[] | null {
|
|
412
|
+
if (!Array.isArray(value)) return null
|
|
413
|
+
const out: string[] = []
|
|
414
|
+
for (const v of value) {
|
|
415
|
+
if (typeof v !== 'string') return null
|
|
416
|
+
const sha = v.trim()
|
|
417
|
+
if (!SHA_RE.test(sha)) return null
|
|
418
|
+
if (!out.includes(sha.toLowerCase())) out.push(sha.toLowerCase())
|
|
419
|
+
}
|
|
420
|
+
return out
|
|
421
|
+
}
|