@gitroll/tacitt-cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{agent-setup-B8DAiQ6Z.mjs → agent-setup-CLXdKr2I.mjs} +1 -1
- package/dist/agent-setup-worker.mjs +4 -4
- package/dist/cli.mjs +65 -8
- package/dist/codex-title-worker.mjs +1 -1
- package/dist/configured-preview-worker.mjs +4 -4
- package/dist/configured-session-worker.mjs +25 -4
- package/dist/{heap-BHcvxWCw.mjs → heap-eO_uWpD1.mjs} +45 -3
- package/dist/inspection-worker.mjs +3 -3
- package/dist/job-rK-Ho9OD.mjs +613 -0
- package/dist/{model-BJdTLNjT.mjs → model-BBAnsFAx.mjs} +1 -1
- package/dist/operation-executor.mjs +2 -2
- package/dist/preview-worker.mjs +1 -1
- package/dist/{query-BA0XCwnR.mjs → query-DN99jiRc.mjs} +273 -551
- package/dist/{run-BeLHsZWb.mjs → run-4G_CvdDj.mjs} +2 -2
- package/dist/session-process.mjs +6 -1
- package/dist/{src-CC95LYYp.mjs → src-B4R5XMEP.mjs} +370 -176
- package/dist/{src-k0Ya1Bri.mjs → src-CPbdnGrk.mjs} +118 -47
- package/dist/{src-B5SArpVY.mjs → src-qoLRvX-A.mjs} +17 -5
- package/dist/store-database-worker.mjs +1 -1
- package/dist/web/assets/index-BkuUI3Eu.js +67 -0
- package/dist/web/assets/index-Clg6Pfv_.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/skills/tacitt/SKILL.md +8 -6
- package/skills/tacitt/references/commands.md +14 -9
- package/skills/tacitt/references/journey.md +10 -6
- package/skills/tacitt/references/send.md +1 -1
- package/skills/tacitt/scripts/INDEX.md +6 -2
- package/skills/tacitt/scripts/journey-card-consolidation.mjs +90 -4
- package/skills/tacitt/scripts/journey-card-values.d.mts +17 -0
- package/skills/tacitt/scripts/journey-card-values.mjs +116 -0
- package/skills/tacitt/scripts/journey-classification.mjs +6 -2
- package/skills/tacitt/scripts/journey-panel-host.d.mts +9 -0
- package/skills/tacitt/scripts/journey-panel-host.mjs +56 -0
- package/skills/tacitt/scripts/journey-state.mjs +10 -4
- package/skills/tacitt/scripts/journey-tasks.mjs +1 -1
- package/skills/tacitt/scripts/journey.d.mts +2 -0
- package/skills/tacitt/scripts/journey.mjs +120 -53
- package/skills/tacitt/scripts/review-stream-client.mjs +1 -1
- package/dist/job-J3NFw3MH.mjs +0 -194
- package/dist/web/assets/index-Bjos73wK.js +0 -67
- package/dist/web/assets/index-Bl4C2b2_.css +0 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { uncoveredPending } from './journey-state.mjs'
|
|
2
|
+
|
|
3
|
+
const keyOf = (term) => term.key ?? JSON.stringify([term.type, term.value?.normalize('NFC')])
|
|
4
|
+
const normalized = (value) => value.normalize('NFC').trim().toLowerCase()
|
|
5
|
+
|
|
6
|
+
/** Examples that differ only in case, spacing, hyphens or underscores show one value. */
|
|
7
|
+
export const exampleKey = (value) =>
|
|
8
|
+
value
|
|
9
|
+
.normalize('NFKC')
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[\s_-]+/gu, '')
|
|
12
|
+
const exampleLength = 80
|
|
13
|
+
const clip = (value) => {
|
|
14
|
+
const points = [...value.trim()]
|
|
15
|
+
return points.length > exampleLength
|
|
16
|
+
? `${points
|
|
17
|
+
.slice(0, exampleLength - 1)
|
|
18
|
+
.join('')
|
|
19
|
+
.trimEnd()}…`
|
|
20
|
+
: points.join('')
|
|
21
|
+
}
|
|
22
|
+
/** Short, distinct examples in their given order; `drop` removes values that belong elsewhere. */
|
|
23
|
+
export function cardExamples(examples, drop = () => false) {
|
|
24
|
+
const seen = new Set(),
|
|
25
|
+
kept = []
|
|
26
|
+
for (const example of examples) {
|
|
27
|
+
if (typeof example !== 'string') continue
|
|
28
|
+
const key = exampleKey(example)
|
|
29
|
+
if (!key || seen.has(key) || drop(example)) continue
|
|
30
|
+
seen.add(key)
|
|
31
|
+
kept.push(clip(example))
|
|
32
|
+
}
|
|
33
|
+
return kept
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Settles a card publication against the current journey before it is sent.
|
|
38
|
+
*
|
|
39
|
+
* - An appended batch submits only values that are still open: a later scan pack may have hidden
|
|
40
|
+
* a covered value between `tasks` and `ingest`, and the CLI refuses the whole batch for it.
|
|
41
|
+
* - Every normalized value goes to one card (F4). A value the scan found as the person's own
|
|
42
|
+
* identity (`own-identity`, or an own-identity value rule) goes to the card that asks about it,
|
|
43
|
+
* so a Hide answer about other people cannot hide the person's own handle. Any other value
|
|
44
|
+
* joins the first card that already covers the same value.
|
|
45
|
+
*
|
|
46
|
+
* Cards left without values are dropped; their extensions too. A new card's examples are
|
|
47
|
+
* deduplicated and shortened, and one that is not the own-identity card shows no own-identity
|
|
48
|
+
* value; when nothing is left it shows its first covered values.
|
|
49
|
+
*/
|
|
50
|
+
export function settleCardValues(submission, view) {
|
|
51
|
+
const terms = new Map(view.privacy.terms.map((term) => [keyOf(term), term]))
|
|
52
|
+
const valueOf = (key) => {
|
|
53
|
+
const value = terms.get(key)?.value
|
|
54
|
+
return typeof value === 'string' ? normalized(value) : `key:${key}`
|
|
55
|
+
}
|
|
56
|
+
const own = new Set(
|
|
57
|
+
[...view.privacy.terms, ...(view.valueOverrides ?? [])]
|
|
58
|
+
.filter((term) => term.type === 'own-identity' && typeof term.value === 'string')
|
|
59
|
+
.map((term) => normalized(term.value)),
|
|
60
|
+
)
|
|
61
|
+
const append = submission.mode === 'append'
|
|
62
|
+
const open = append ? new Set(uncoveredPending(view).map(keyOf)) : null
|
|
63
|
+
const proposed = new Map([
|
|
64
|
+
...(submission.extend ?? []).map((item) => [item.cardId, item.termKeys]),
|
|
65
|
+
...submission.cards.map((card) => [card.id, card.termKeys]),
|
|
66
|
+
])
|
|
67
|
+
const slots = [
|
|
68
|
+
...(append ? view.privacy.cards : []).map((card) => ({
|
|
69
|
+
id: card.id,
|
|
70
|
+
published: card.termKeys,
|
|
71
|
+
keys: [],
|
|
72
|
+
})),
|
|
73
|
+
...submission.cards.map((card) => ({ id: card.id, published: [], keys: [] })),
|
|
74
|
+
]
|
|
75
|
+
const ownCount = (slot) =>
|
|
76
|
+
[...slot.published, ...(proposed.get(slot.id) ?? [])].filter(
|
|
77
|
+
(key) => terms.get(key)?.type === 'own-identity',
|
|
78
|
+
).length
|
|
79
|
+
const ownSlot = slots.reduce(
|
|
80
|
+
(best, slot) => (ownCount(slot) > (best ? ownCount(best) : 0) ? slot : best),
|
|
81
|
+
null,
|
|
82
|
+
)
|
|
83
|
+
const home = new Map()
|
|
84
|
+
for (const slot of slots)
|
|
85
|
+
for (const key of slot.published) if (!home.has(valueOf(key))) home.set(valueOf(key), slot)
|
|
86
|
+
for (const slot of slots)
|
|
87
|
+
for (const key of proposed.get(slot.id) ?? []) {
|
|
88
|
+
if (open && !open.has(key)) continue
|
|
89
|
+
const value = valueOf(key)
|
|
90
|
+
const target = (own.has(value) && ownSlot) || home.get(value) || slot
|
|
91
|
+
if (!home.has(value)) home.set(value, target)
|
|
92
|
+
target.keys.push(key)
|
|
93
|
+
}
|
|
94
|
+
const settled = new Map(slots.map((slot) => [slot.id, slot.keys]))
|
|
95
|
+
const created = new Set(submission.cards.map((card) => card.id))
|
|
96
|
+
const extend = slots
|
|
97
|
+
.filter((slot) => !created.has(slot.id) && slot.keys.length)
|
|
98
|
+
.map((slot) => ({ cardId: slot.id, termKeys: slot.keys }))
|
|
99
|
+
const { extend: _proposedExtend, ...rest } = submission
|
|
100
|
+
const examplesOf = (card, keys) => {
|
|
101
|
+
const drop = card.id === ownSlot?.id ? () => false : (example) => own.has(normalized(example))
|
|
102
|
+
const examples = cardExamples(card.examples ?? [], drop)
|
|
103
|
+
const covered = cardExamples(keys.map((key) => terms.get(key)?.value)).slice(0, 3)
|
|
104
|
+
return examples.length ? examples : covered.length ? covered : card.examples
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
...rest,
|
|
108
|
+
cards: submission.cards
|
|
109
|
+
.map((card) => {
|
|
110
|
+
const termKeys = settled.get(card.id) ?? []
|
|
111
|
+
return { ...card, examples: examplesOf(card, termKeys), termKeys }
|
|
112
|
+
})
|
|
113
|
+
.filter((card) => card.termKeys.length),
|
|
114
|
+
...(extend.length ? { extend } : {}),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -74,9 +74,13 @@ export async function classificationEvidence(directory, manifest, view, call, li
|
|
|
74
74
|
const root = state.roots.find((entry) => entry.key === key)
|
|
75
75
|
const source = state.unknown.find((entry) => entry.rootKey === key)
|
|
76
76
|
if (!root || !source) throw new Error('INVALID_EVIDENCE_ROOTS')
|
|
77
|
+
// After the known Works (E2), unresolved roots sit in the "Still being grouped" Work.
|
|
78
|
+
const workId =
|
|
79
|
+
view.works.find((work) => work.rootSessionIds?.includes(root.rootSessionId))?.id ??
|
|
80
|
+
root.workId
|
|
77
81
|
const page = await call('journey.classificationSessions', {
|
|
78
82
|
journeyId: view.journeyId,
|
|
79
|
-
workId
|
|
83
|
+
workId,
|
|
80
84
|
includeProjectEvidence: true,
|
|
81
85
|
projectEvidenceRootSessionIds: [root.rootSessionId],
|
|
82
86
|
rootsOnly: true,
|
|
@@ -97,7 +101,7 @@ export async function classificationEvidence(directory, manifest, view, call, li
|
|
|
97
101
|
for (let pageNumber = 0; pageNumber < 2; pageNumber++) {
|
|
98
102
|
const content = await call('journey.classificationRead', {
|
|
99
103
|
journeyId: view.journeyId,
|
|
100
|
-
workId
|
|
104
|
+
workId,
|
|
101
105
|
rootSessionId: root.rootSessionId,
|
|
102
106
|
...(cursor ? { cursor } : {}),
|
|
103
107
|
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ChildProcess, type SpawnOptions } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export function ensureDetachedHost(
|
|
4
|
+
cli: string[],
|
|
5
|
+
journeyId: string,
|
|
6
|
+
options?: {
|
|
7
|
+
spawnChild?: (command: string, args: string[], options: SpawnOptions) => ChildProcess
|
|
8
|
+
},
|
|
9
|
+
): Promise<{ url: string; pid: number; started: boolean }>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { windowsArgument } from './review-stream-client.mjs'
|
|
3
|
+
|
|
4
|
+
/** `journey.serve --detach` waits up to 60 s for the host; leave room for the CLI's startup. */
|
|
5
|
+
const detachTimeoutMs = 90_000
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ADR 0227: before the agent ends its turn at Ready to send, a detached panel host must own the
|
|
9
|
+
* panel, because some agent hosts (Codex) stop the processes of a turn when it ends. Runs the
|
|
10
|
+
* paired CLI's `journey.serve --detach`, which reuses a detached host already serving the journey
|
|
11
|
+
* and otherwise starts one. Resolves `{url, pid, started}`; `started` means the panel moved to
|
|
12
|
+
* a new URL.
|
|
13
|
+
*/
|
|
14
|
+
export function ensureDetachedHost(cli, journeyId, { spawnChild = spawn } = {}) {
|
|
15
|
+
const [command, ...prefix] = cli
|
|
16
|
+
const shell = process.platform === 'win32' && !/\.(exe|com)$/iu.test(command)
|
|
17
|
+
const args = [...prefix, 'journey.serve', '--detach', '--input', '-']
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const fail = () => reject(new Error('PANEL_HOST_DETACH_FAILED'))
|
|
20
|
+
const child = spawnChild(command, shell ? args.map(windowsArgument) : args, {
|
|
21
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
22
|
+
shell,
|
|
23
|
+
})
|
|
24
|
+
let output = ''
|
|
25
|
+
const timer = setTimeout(() => child.kill(), detachTimeoutMs)
|
|
26
|
+
child.stdout.setEncoding('utf8')
|
|
27
|
+
child.stdout.on('data', (chunk) => (output += chunk))
|
|
28
|
+
child.once('error', () => {
|
|
29
|
+
clearTimeout(timer)
|
|
30
|
+
fail()
|
|
31
|
+
})
|
|
32
|
+
child.once('close', (code) => {
|
|
33
|
+
clearTimeout(timer)
|
|
34
|
+
const ready = output
|
|
35
|
+
.split('\n')
|
|
36
|
+
.flatMap((line) => {
|
|
37
|
+
try {
|
|
38
|
+
return [JSON.parse(line)]
|
|
39
|
+
} catch {
|
|
40
|
+
return []
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
.find((line) => line?.ok === true && line.event === 'ready')?.result
|
|
44
|
+
if (
|
|
45
|
+
code !== 0 ||
|
|
46
|
+
ready?.journeyId !== journeyId ||
|
|
47
|
+
typeof ready.url !== 'string' ||
|
|
48
|
+
!Number.isSafeInteger(ready.pid)
|
|
49
|
+
)
|
|
50
|
+
return fail()
|
|
51
|
+
resolve({ url: ready.url, pid: ready.pid, started: ready.reused !== true })
|
|
52
|
+
})
|
|
53
|
+
child.stdin.on('error', () => {})
|
|
54
|
+
child.stdin.end(JSON.stringify({ journeyId }))
|
|
55
|
+
})
|
|
56
|
+
}
|
|
@@ -65,7 +65,8 @@ export async function cardBatchOpen(run) {
|
|
|
65
65
|
const manifest = await optional(join(active.directory, 'manifest.json'))
|
|
66
66
|
if (!manifest?.batch) return false
|
|
67
67
|
const receipt = await optional(join(active.directory, 'receipt.json'))
|
|
68
|
-
|
|
68
|
+
// A refused batch is retired and an emptied one finished: neither holds the next batch back.
|
|
69
|
+
return !['applied', 'replayed', 'skipped', 'rejected'].includes(receipt?.status)
|
|
69
70
|
}
|
|
70
71
|
/** Pending values that no published card covers yet: the input of the next card batch. */
|
|
71
72
|
export function uncoveredPending(view) {
|
|
@@ -76,16 +77,21 @@ export function uncoveredPending(view) {
|
|
|
76
77
|
!covered.has(term.key ?? JSON.stringify([term.type, term.value?.normalize('NFC')])),
|
|
77
78
|
)
|
|
78
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Classification tasks depend on grouping, not on scan progress: the known Works' scan keeps
|
|
82
|
+
* moving their input revision and the person may change access, so neither retires them.
|
|
83
|
+
*/
|
|
79
84
|
export function stageFingerprint(view, stage) {
|
|
85
|
+
const classification = stage === 'classification'
|
|
80
86
|
return hash({
|
|
81
87
|
snapshotId: view.snapshotId,
|
|
82
88
|
guidance: view.guidanceDigest,
|
|
83
89
|
works: view.works.map((work) => [
|
|
84
90
|
work.id,
|
|
85
|
-
work.inputRevision,
|
|
91
|
+
classification ? null : work.inputRevision,
|
|
86
92
|
work.rootSessionIds,
|
|
87
|
-
work.access,
|
|
88
|
-
|
|
93
|
+
classification ? null : work.access,
|
|
94
|
+
classification
|
|
89
95
|
? [
|
|
90
96
|
work.title,
|
|
91
97
|
work.projectName,
|
|
@@ -122,7 +122,7 @@ export async function createTasks(directory, stage, view, call, { batch = false
|
|
|
122
122
|
for (const group of groups) {
|
|
123
123
|
const instruction =
|
|
124
124
|
stage === 'cards'
|
|
125
|
-
?
|
|
125
|
+
? "INTERNAL decision-card drafting for Main (not browser publication): return {cards:[{id,question,examples,termRefs,options:[{id,label,rule}]}]}. Use UUID card IDs. termRefs lists the supplied term refs (for example \"T12\"); cover every supplied ref exactly once. Write the question, option labels, rules and any context in English whatever language the conversation uses; keep example values exactly as they appear in the source. Ask a concise contextual retention question with exactly two options: id hide (hide the covered values) and id keep (retain the covered values). Keep their rules meaningful for final resolution. Group by broad privacy-information category and shared retention policy. Specific projects, tools, filenames and capabilities belong in examples, not separate question topics. Never put the person's own identity and other people on one card: an own-identity card covers only values tied to the person's own signals (own-identity terms, the session author, local account, own handle, email or domain); when unsure whose a value is, put it on the other-people card. Common review or chat jargon (for example LGTM, PTAL, nit, WIP) and generic words are not privacy values: never use them as examples or as a question's subject, and cover their refs on the card that fits the context they came from. Examples show short values, never sentences: list each value once, ignoring case, spacing, hyphens and underscores, and cut a long value with a trailing ellipsis. Main alone reads the complete candidate catalog and reconciles drafts globally into 5–10 final questions for the entire journey, never more than 20. Preserve coherent decision boundaries and exact disjoint keys; batch boundaries must never become final questions. Identify affected Work names only as context. Never answer for the person."
|
|
126
126
|
: 'Final privacy resolution: return {resolutions:[{type,value,disposition,reason}]}. Resolve every supplied term strictly by the confirmed card answer: Hide gives "hidden" and Keep gives "keep" for every covered term; never override the answer of the person with your own judgment; when the same value is also under another card answered Hide, the CLI makes it Hidden, so still resolve by the answer of this card (credentials and contact details are masked separately and never reach these tasks). Use guidance only for a term the answer does not settle. disposition MUST be exactly the string "hidden" or "keep" (the card option id "hide" means "hidden"), never pending. If rules cannot settle a value, report explicit failure instead of inventing an answer.'
|
|
127
127
|
const base = stage === 'cards' ? {} : { card: { ...group.card, termKeys: undefined }, guidance }
|
|
128
128
|
const dataFor = (terms) => {
|
|
@@ -35,6 +35,8 @@ export function runPhase(
|
|
|
35
35
|
limit?: number
|
|
36
36
|
waitMs?: number
|
|
37
37
|
sleep?: (ms: number) => Promise<void>
|
|
38
|
+
/** Ensures a detached panel host at a handoff phase (ADR 0227). */
|
|
39
|
+
ensureHost?: () => Promise<{ url: string; pid: number; started: boolean }>
|
|
38
40
|
},
|
|
39
41
|
call: (procedure: string, input: unknown) => Promise<unknown>,
|
|
40
42
|
): Promise<Record<string, unknown>>
|
|
@@ -3,7 +3,7 @@ import { mkdir, realpath, rm } from 'node:fs/promises'
|
|
|
3
3
|
import { join, resolve, isAbsolute } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { parseArgs } from 'node:util'
|
|
6
|
-
import { main as review, withJourneyCli } from './review.mjs'
|
|
6
|
+
import { blockedWorks, main as review, withJourneyCli } from './review.mjs'
|
|
7
7
|
import {
|
|
8
8
|
read,
|
|
9
9
|
optional,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
partialClassificationSubmission,
|
|
29
29
|
} from './journey-consolidation.mjs'
|
|
30
30
|
import { consolidateCards, cardSubmission } from './journey-card-consolidation.mjs'
|
|
31
|
+
import { settleCardValues } from './journey-card-values.mjs'
|
|
31
32
|
import {
|
|
32
33
|
classificationProgress,
|
|
33
34
|
publishClassificationProgress,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
} from './journey-classification-progress.mjs'
|
|
36
37
|
import { answers, createTasks } from './journey-tasks.mjs'
|
|
37
38
|
import { scanTiming } from './journey-scan-timing.mjs'
|
|
39
|
+
import { ensureDetachedHost } from './journey-panel-host.mjs'
|
|
38
40
|
export { phaseOf, stageFingerprint } from './journey-state.mjs'
|
|
39
41
|
|
|
40
42
|
async function publishCompleteClassification(manifest, view, call, proposalRevision) {
|
|
@@ -116,19 +118,22 @@ async function observeScanTiming(run, journeyId, view, start, call) {
|
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
const packagedCount = (view) =>
|
|
122
|
+
view.works.filter(
|
|
123
|
+
(work) =>
|
|
124
|
+
work.access !== 'drop' &&
|
|
125
|
+
work.rootSessionIds.length &&
|
|
126
|
+
work.status === 'ready' &&
|
|
127
|
+
work.artifact?.revision === work.revision,
|
|
128
|
+
).length
|
|
129
|
+
|
|
119
130
|
/** Locked by withJourneyCli in production; injected transport permits deterministic crash tests. */
|
|
120
131
|
/** A running journey render (panel or CLI) with packaged/selected Work counts (BL-185). */
|
|
121
132
|
function packagingProgress(view) {
|
|
122
133
|
if (!view.packaging) return {}
|
|
123
134
|
const selected = view.works.filter((work) => work.access !== 'drop' && work.rootSessionIds.length)
|
|
124
135
|
return {
|
|
125
|
-
packaging: {
|
|
126
|
-
...view.packaging,
|
|
127
|
-
selected: selected.length,
|
|
128
|
-
packaged: selected.filter(
|
|
129
|
-
(work) => work.status === 'ready' && work.artifact?.revision === work.revision,
|
|
130
|
-
).length,
|
|
131
|
-
},
|
|
136
|
+
packaging: { ...view.packaging, selected: selected.length, packaged: packagedCount(view) },
|
|
132
137
|
}
|
|
133
138
|
}
|
|
134
139
|
|
|
@@ -136,28 +141,49 @@ function packagingProgress(view) {
|
|
|
136
141
|
export const maxAwaitMs = 540_000
|
|
137
142
|
const awaitPollMs = 1000
|
|
138
143
|
|
|
144
|
+
/**
|
|
145
|
+
* ADR 0227: from Ready to send the AI work is done. Packaging, upload, assessment and Passport
|
|
146
|
+
* completion run in the detached panel host (ADR 0217), so the agent ends its turn.
|
|
147
|
+
*/
|
|
148
|
+
const handoffPhases = new Set(['ready-to-send', 'send', 'sending'])
|
|
149
|
+
|
|
139
150
|
/**
|
|
140
151
|
* D0: the agent waits inside its turn for the person's next panel decision. Returns once the
|
|
141
152
|
* helper phase changes (consent recorded, the last card answered, a Work moved out of Drop
|
|
142
|
-
* needing a read)
|
|
153
|
+
* needing a read), a new card batch is due or a blocked Work was Dropped or retried, else after
|
|
154
|
+
* `waitMs` with `changed: false`. A retried Work needs `journey.prepare` again.
|
|
155
|
+
*
|
|
156
|
+
* At a handoff phase it returns at once, without waiting, once a detached panel host owns the
|
|
157
|
+
* panel: `handoff` tells the agent to end its turn (ADR 0227, superseding ADR 0225's wait).
|
|
143
158
|
*/
|
|
144
|
-
async function awaitDecision(journeyId, waitMs, call, sleep) {
|
|
159
|
+
async function awaitDecision(journeyId, waitMs, call, sleep, ensureHost) {
|
|
145
160
|
if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > maxAwaitMs)
|
|
146
161
|
throw new Error('INVALID_ARGUMENTS')
|
|
147
162
|
const started = Date.now()
|
|
148
163
|
let view = await call('journey.get', { journeyId })
|
|
149
164
|
const from = phaseOf(view)
|
|
165
|
+
const blockedFrom = blockedWorks(view).map((work) => work.workId)
|
|
150
166
|
for (;;) {
|
|
151
167
|
const phase = phaseOf(view)
|
|
152
168
|
const batchDue = phase === 'browser-answers' && uncoveredPending(view).length > 0
|
|
153
|
-
|
|
169
|
+
const blocked = blockedWorks(view)
|
|
170
|
+
const settled = blockedFrom.filter((id) => !blocked.some((work) => work.workId === id))
|
|
171
|
+
const retried = settled.filter((id) =>
|
|
172
|
+
view.works.some((work) => work.id === id && work.access !== 'drop'),
|
|
173
|
+
)
|
|
174
|
+
const changed = phase !== from || batchDue || settled.length > 0
|
|
175
|
+
const handoff = handoffPhases.has(phase)
|
|
176
|
+
if (changed || handoff || Date.now() - started >= waitMs)
|
|
154
177
|
return {
|
|
155
178
|
command: 'await',
|
|
156
179
|
from,
|
|
157
180
|
phase,
|
|
158
|
-
changed
|
|
181
|
+
changed,
|
|
159
182
|
waitedMs: Date.now() - started,
|
|
160
183
|
revision: view.revision,
|
|
184
|
+
blockedWorks: blocked,
|
|
185
|
+
...(retried.length ? { retriedWorks: retried, next: 'journey.prepare' } : {}),
|
|
186
|
+
...(handoff ? { handoff: { panel: await ensureHost(), next: 'end-turn' } } : {}),
|
|
161
187
|
}
|
|
162
188
|
await sleep(Math.min(awaitPollMs, Math.max(0, waitMs - (Date.now() - started))))
|
|
163
189
|
view = await call('journey.get', { journeyId })
|
|
@@ -165,7 +191,7 @@ async function awaitDecision(journeyId, waitMs, call, sleep) {
|
|
|
165
191
|
}
|
|
166
192
|
|
|
167
193
|
async function runPhaseCore(
|
|
168
|
-
{ run, journeyId, command, stage, refresh = false, limit = 3, waitMs = 0, sleep },
|
|
194
|
+
{ run, journeyId, command, stage, refresh = false, limit = 3, waitMs = 0, sleep, ensureHost },
|
|
169
195
|
call,
|
|
170
196
|
) {
|
|
171
197
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 16) throw new Error('INVALID_ARGUMENTS')
|
|
@@ -175,6 +201,10 @@ async function runPhaseCore(
|
|
|
175
201
|
waitMs,
|
|
176
202
|
call,
|
|
177
203
|
sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
204
|
+
ensureHost ??
|
|
205
|
+
(() => {
|
|
206
|
+
throw new Error('PANEL_HOST_DETACH_FAILED')
|
|
207
|
+
}),
|
|
178
208
|
)
|
|
179
209
|
let view = await call('journey.get', { journeyId })
|
|
180
210
|
if (command === 'scan-start')
|
|
@@ -202,6 +232,7 @@ async function runPhaseCore(
|
|
|
202
232
|
revision: view.revision,
|
|
203
233
|
readingComplete: view.readingComplete,
|
|
204
234
|
pending: activeTerms(view).filter((term) => term.disposition === 'pending').length,
|
|
235
|
+
blockedWorks: blockedWorks(view),
|
|
205
236
|
answersRevision: view.privacy.answersRevision,
|
|
206
237
|
...packagingProgress(view),
|
|
207
238
|
coldElapsedMs,
|
|
@@ -224,14 +255,22 @@ async function runPhaseCore(
|
|
|
224
255
|
const recovery = await recoverWrites(active.directory, manifest, call)
|
|
225
256
|
// A failed transport leaves the exact pending request intact and never reaches refresh.
|
|
226
257
|
if (recovery.recovered) view = await call('journey.get', { journeyId })
|
|
227
|
-
|
|
228
|
-
if (
|
|
258
|
+
// Only scan progress refused a classification-bound proposal: it is still current (ADR 0225).
|
|
259
|
+
if (
|
|
260
|
+
recovery.stale &&
|
|
261
|
+
(!refresh || command !== 'tasks') &&
|
|
262
|
+
!(stage === 'classification' && classificationBound(manifest, view))
|
|
263
|
+
)
|
|
264
|
+
throw new Error('REVISION_CONFLICT')
|
|
265
|
+
// A refused card batch is retired: the next `tasks` drafts the current Pending set.
|
|
266
|
+
if (recovery.rejected && !(command === 'tasks' && (refresh || manifest.batch))) {
|
|
229
267
|
const error = new Error(recovery.rejected)
|
|
230
268
|
error.code = recovery.rejected
|
|
231
269
|
throw error
|
|
232
270
|
}
|
|
233
271
|
if (recovery.completed === recovery.total && !recovery.stale && command === 'ingest') {
|
|
234
|
-
|
|
272
|
+
// After the known Works, scans keep moving the revision and Works show grouping (E2).
|
|
273
|
+
if (stage === 'classification' && manifest.consolidation && !manifest.partial)
|
|
235
274
|
await publishCompleteClassification(
|
|
236
275
|
manifest,
|
|
237
276
|
view,
|
|
@@ -250,15 +289,15 @@ async function runPhaseCore(
|
|
|
250
289
|
throw new Error('STAGE_NOT_READY')
|
|
251
290
|
if (consolidationRefresh && (await optional(join(active?.directory ?? run, 'receipt.json'))))
|
|
252
291
|
throw new Error('CLASSIFICATION_ALREADY_SUBMITTED')
|
|
253
|
-
// A card batch (D3) whose append was accepted
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
manifest?.batch
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (batchDone && command !== 'tasks') return { stage, status:
|
|
261
|
-
if (batchDone) manifest = null
|
|
292
|
+
// A card batch (D3) whose append was accepted, or had no open value left, is finished; one
|
|
293
|
+
// the CLI refused is retired. Either way the next `tasks` starts another.
|
|
294
|
+
const batchReceipt =
|
|
295
|
+
stage === 'cards' && manifest?.batch
|
|
296
|
+
? (await optional(join(active.directory, 'receipt.json')))?.status
|
|
297
|
+
: undefined
|
|
298
|
+
const batchDone = ['applied', 'replayed', 'skipped'].includes(batchReceipt)
|
|
299
|
+
if (batchDone && command !== 'tasks') return { stage, status: batchReceipt, batchDone: true }
|
|
300
|
+
if (batchDone || batchReceipt === 'rejected') manifest = null
|
|
262
301
|
const fingerprint = stageFingerprint(view, stage)
|
|
263
302
|
if (manifest?.resolutionBatches && !refresh)
|
|
264
303
|
await assertResolutionFresh(active.directory, manifest, view)
|
|
@@ -277,10 +316,11 @@ async function runPhaseCore(
|
|
|
277
316
|
const phase = phaseOf(view)
|
|
278
317
|
const revisingCards =
|
|
279
318
|
stage === 'cards' && refresh && ['browser-answers', 'answers-confirmation'].includes(phase)
|
|
280
|
-
// Batches start while reading continues and after earlier batches (D3)
|
|
319
|
+
// Batches start while reading continues and after earlier batches (D3); while reading
|
|
320
|
+
// continues there is no deck to replace, so `--refresh` also starts a batch.
|
|
281
321
|
const cardBatch =
|
|
282
322
|
stage === 'cards' &&
|
|
283
|
-
!refresh &&
|
|
323
|
+
(!refresh || phase === 'scan') &&
|
|
284
324
|
view.scope.status === 'confirmed' &&
|
|
285
325
|
!['paused', 'sending', 'completed'].includes(phase) &&
|
|
286
326
|
uncoveredPending(view).length > 0
|
|
@@ -335,6 +375,10 @@ async function runPhaseCore(
|
|
|
335
375
|
fingerprint,
|
|
336
376
|
tasks: stage === 'classification' ? [] : await createTasks(directory, stage, view, call),
|
|
337
377
|
answersRevision: view.privacy.answersRevision,
|
|
378
|
+
// Refreshed after the known Works (E2): the complete proposal stays bound to classification.
|
|
379
|
+
...(stage === 'classification' && grouping(view)
|
|
380
|
+
? { partial: { status: 'applied', revision: view.classificationRevision } }
|
|
381
|
+
: {}),
|
|
338
382
|
}
|
|
339
383
|
await save(join(directory, 'manifest.json'), manifest)
|
|
340
384
|
await save(activePath, { directory })
|
|
@@ -434,36 +478,50 @@ async function runPhaseCore(
|
|
|
434
478
|
}
|
|
435
479
|
const key = stage === 'classification' ? 'items' : stage === 'cards' ? 'cards' : 'resolutions'
|
|
436
480
|
const tasks = stage === 'classification' ? [manifest.consolidation] : manifest.tasks
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
? { expectedClassificationRevision: manifest.partial.revision }
|
|
444
|
-
: {}),
|
|
445
|
-
...(stage === 'classification'
|
|
446
|
-
? { [key]: await classificationSubmission(manifest, view) }
|
|
447
|
-
: stage === 'cards'
|
|
448
|
-
? await cardSubmission(manifest)
|
|
449
|
-
: { [key]: await answers(tasks, key) }),
|
|
450
|
-
...(stage === 'resolution' ? { answersRevision: manifest.answersRevision } : {}),
|
|
481
|
+
// Later scan packs may hide covered values between `tasks` and `ingest`: submit only what is
|
|
482
|
+
// still open, and give every value one card (F4).
|
|
483
|
+
const cards = stage === 'cards' ? settleCardValues(await cardSubmission(manifest), view) : null
|
|
484
|
+
if (cards && manifest.batch && !cards.cards.length && !cards.extend?.length) {
|
|
485
|
+
await save(join(directory, 'receipt.json'), { status: 'skipped' })
|
|
486
|
+
return { stage, status: 'skipped', batchDone: true }
|
|
451
487
|
}
|
|
452
|
-
await save(join(directory, 'request.json'), request)
|
|
453
488
|
let result
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
489
|
+
for (let attempt = 0; ; attempt++) {
|
|
490
|
+
const request = {
|
|
491
|
+
journeyId,
|
|
492
|
+
expectedRevision: view.revision,
|
|
493
|
+
idempotencyKey: randomUUID(),
|
|
494
|
+
// After the known Works, scans keep moving the revision: bind to the classification (E2).
|
|
495
|
+
...(stage === 'classification' && manifest.partial
|
|
496
|
+
? { expectedClassificationRevision: manifest.partial.revision }
|
|
497
|
+
: {}),
|
|
498
|
+
...(stage === 'classification'
|
|
499
|
+
? { [key]: await classificationSubmission(manifest, view) }
|
|
500
|
+
: stage === 'cards'
|
|
501
|
+
? cards
|
|
502
|
+
: { [key]: await answers(tasks, key) }),
|
|
503
|
+
...(stage === 'resolution' ? { answersRevision: manifest.answersRevision } : {}),
|
|
504
|
+
}
|
|
505
|
+
await save(join(directory, 'request.json'), request)
|
|
506
|
+
try {
|
|
507
|
+
result = await call(procedureFor(stage), request)
|
|
462
508
|
await save(join(directory, 'receipt.json'), {
|
|
463
|
-
status:
|
|
464
|
-
|
|
509
|
+
status: result.status,
|
|
510
|
+
revision: result.state?.revision,
|
|
465
511
|
})
|
|
466
|
-
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (definiteWriteRejection(error))
|
|
514
|
+
await save(join(directory, 'receipt.json'), {
|
|
515
|
+
status: 'rejected',
|
|
516
|
+
code: error.code,
|
|
517
|
+
})
|
|
518
|
+
throw error
|
|
519
|
+
}
|
|
520
|
+
if (result.status !== 'stale' || stage !== 'classification' || attempt >= boundRetries) break
|
|
521
|
+
// A scan write between the CLI's read and its commit refuses a still-current proposal
|
|
522
|
+
// (ADR 0225): submit it again against the current revision.
|
|
523
|
+
view = await call('journey.get', { journeyId })
|
|
524
|
+
if (!classificationBound(manifest, view)) break
|
|
467
525
|
}
|
|
468
526
|
if (result.status === 'stale') throw new Error('REVISION_CONFLICT')
|
|
469
527
|
if (stage === 'classification' && !manifest.partial) {
|
|
@@ -476,6 +534,14 @@ async function runPhaseCore(
|
|
|
476
534
|
/** Classification published known Works and is still grouping the remaining roots (E2). */
|
|
477
535
|
const grouping = (view) => view.works.some((work) => work.placement === 'classifying')
|
|
478
536
|
|
|
537
|
+
/** The complete proposal binds to the classification it replaces, not to scan progress (E2). */
|
|
538
|
+
const classificationBound = (manifest, view) =>
|
|
539
|
+
Boolean(manifest.partial) &&
|
|
540
|
+
view.classificationRevision === manifest.partial.revision &&
|
|
541
|
+
grouping(view)
|
|
542
|
+
/** Submissions of an unchanged classification-bound proposal after a stale answer. */
|
|
543
|
+
const boundRetries = 3
|
|
544
|
+
|
|
479
545
|
/**
|
|
480
546
|
* E2: publishes known-project Works while unresolved roots are still being grouped, so their
|
|
481
547
|
* scan can start. Its request and receipt are kept apart from the final proposal's.
|
|
@@ -575,6 +641,7 @@ export async function main(argv) {
|
|
|
575
641
|
refresh: values.refresh,
|
|
576
642
|
limit: values.limit === undefined ? 3 : Number(values.limit),
|
|
577
643
|
waitMs: values['wait-ms'] === undefined ? 300_000 : Number(values['wait-ms']),
|
|
644
|
+
ensureHost: () => ensureDetachedHost(config.cli, config.journeyId),
|
|
578
645
|
},
|
|
579
646
|
call,
|
|
580
647
|
),
|