@seasonkoh/webaz 0.1.25 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +2 -2
  2. package/NOTICE +24 -3
  3. package/README.md +74 -328
  4. package/README.zh-CN.md +419 -0
  5. package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +21 -0
  6. package/dist/layer0-foundation/L0-5-manifest/manifest.js +8 -3
  7. package/dist/layer1-agent/L1-1-mcp-server/auth.js +13 -1
  8. package/dist/layer1-agent/L1-1-mcp-server/server.js +164 -177
  9. package/dist/layer2-business/L2-9-contribution/admin-coordination-ingestion-engine.js +181 -0
  10. package/dist/layer2-business/L2-9-contribution/admin-coordination-resolver.js +114 -0
  11. package/dist/layer2-business/L2-9-contribution/admin-coordination-store.js +251 -0
  12. package/dist/layer2-business/L2-9-contribution/admin-operator-claim-workflow.js +390 -0
  13. package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +33 -0
  14. package/dist/layer2-business/L2-9-contribution/build-task-participation.js +6 -2
  15. package/dist/layer2-business/L2-9-contribution/build-task-quota.js +337 -0
  16. package/dist/layer2-business/L2-9-contribution/build-task-read.js +25 -2
  17. package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +58 -8
  18. package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +1 -1
  19. package/dist/layer2-business/L2-9-contribution/contribution-facts-read.js +66 -0
  20. package/dist/layer2-business/L2-9-contribution/identity-claim-discovery.js +55 -0
  21. package/dist/layer2-business/L2-9-contribution/task-proposal-ai-store.js +99 -0
  22. package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +360 -0
  23. package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +29 -4
  24. package/dist/ledger.js +1 -1
  25. package/dist/pwa/admin-audit.js +38 -0
  26. package/dist/pwa/admin-bearer-auth.js +21 -0
  27. package/dist/pwa/anti-abuse-thresholds.js +135 -0
  28. package/dist/pwa/cf-origin-guard.js +33 -0
  29. package/dist/pwa/contract-fingerprint.js +1 -0
  30. package/dist/pwa/data/onboarding-cases.js +2 -2
  31. package/dist/pwa/data/onboarding-quiz.js +1 -1
  32. package/dist/pwa/economic-participation.js +2 -2
  33. package/dist/pwa/email-delivery.js +127 -0
  34. package/dist/pwa/integration-contract.js +46 -4
  35. package/dist/pwa/internal/pv-settlement.js +12 -0
  36. package/dist/pwa/internal/wallet-signer.js +26 -0
  37. package/dist/pwa/public/app.js +1607 -912
  38. package/dist/pwa/public/i18n.js +284 -68
  39. package/dist/pwa/public/index.html +1 -1
  40. package/dist/pwa/public/openapi.json +4760 -2769
  41. package/dist/pwa/public/whitepaper/en/index.html +153 -0
  42. package/dist/pwa/public/whitepaper/zh-CN/index.html +153 -0
  43. package/dist/pwa/pv-kill-switch.js +31 -0
  44. package/dist/pwa/routes/admin-admins.js +48 -1
  45. package/dist/pwa/routes/admin-analytics.js +1 -10
  46. package/dist/pwa/routes/admin-atomic.js +7 -14
  47. package/dist/pwa/routes/admin-moderation.js +25 -1
  48. package/dist/pwa/routes/admin-operator-claims.js +280 -0
  49. package/dist/pwa/routes/admin-ops.js +13 -2
  50. package/dist/pwa/routes/admin-reports.js +4 -26
  51. package/dist/pwa/routes/admin-tokenomics.js +2 -76
  52. package/dist/pwa/routes/admin-users-lifecycle.js +1 -14
  53. package/dist/pwa/routes/admin-users-query.js +35 -2
  54. package/dist/pwa/routes/admin-wallet-ops.js +26 -3
  55. package/dist/pwa/routes/auction.js +4 -2
  56. package/dist/pwa/routes/auth-read.js +11 -6
  57. package/dist/pwa/routes/auth-register.js +84 -24
  58. package/dist/pwa/routes/build-task-quota.js +113 -0
  59. package/dist/pwa/routes/claim-verify.js +15 -11
  60. package/dist/pwa/routes/contribution-facts.js +18 -0
  61. package/dist/pwa/routes/contribution-identity.js +17 -0
  62. package/dist/pwa/routes/dispute-cases.js +5 -4
  63. package/dist/pwa/routes/growth.js +4 -4
  64. package/dist/pwa/routes/orders-action.js +46 -23
  65. package/dist/pwa/routes/orders-create.js +1 -1
  66. package/dist/pwa/routes/products-meta.js +19 -6
  67. package/dist/pwa/routes/profile-credentials.js +7 -4
  68. package/dist/pwa/routes/profile-placement.js +8 -9
  69. package/dist/pwa/routes/promoter.js +11 -44
  70. package/dist/pwa/routes/public-build-tasks.js +5 -1
  71. package/dist/pwa/routes/public-utils.js +9 -12
  72. package/dist/pwa/routes/ratings.js +64 -4
  73. package/dist/pwa/routes/recover-key.js +58 -19
  74. package/dist/pwa/routes/referral.js +9 -50
  75. package/dist/pwa/routes/rewards-apply.js +3 -2
  76. package/dist/pwa/routes/share-redirects.js +5 -4
  77. package/dist/pwa/routes/shareables-interactions.js +2 -1
  78. package/dist/pwa/routes/shop-referral.js +6 -5
  79. package/dist/pwa/routes/shops.js +5 -2
  80. package/dist/pwa/routes/task-proposals.js +159 -7
  81. package/dist/pwa/routes/trial.js +4 -2
  82. package/dist/pwa/routes/users-public.js +1 -14
  83. package/dist/pwa/routes/wallet-read.js +3 -15
  84. package/dist/pwa/routes/webauthn.js +1 -1
  85. package/dist/pwa/server.js +223 -478
  86. package/dist/settlement-math.js +3 -3
  87. package/dist/version.js +6 -4
  88. package/package.json +62 -8
  89. package/dist/index.js +0 -182
  90. package/dist/pwa/public/docs/ECONOMIC-MODEL.md +0 -287
  91. package/dist/pwa/public/docs/INTEGRATOR.md +0 -67
  92. package/dist/pwa/public/docs/META-RULES-FULL.md +0 -543
  93. package/dist/test-dispute.js +0 -153
  94. package/dist/test-manifest.js +0 -61
  95. package/dist/test-mcp-tools.js +0 -135
  96. package/dist/test-reputation.js +0 -116
  97. package/dist/test-skill-market.js +0 -101
@@ -0,0 +1,99 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
3
+ export function initTaskProposalAiSchema(db) {
4
+ // CREATE only (no ALTER) — additive, never blocks an existing fresh-DB boot.
5
+ db.exec(`CREATE TABLE IF NOT EXISTS task_proposal_ai_suggestions (
6
+ id TEXT PRIMARY KEY,
7
+ proposal_id TEXT NOT NULL,
8
+ reviewer_type TEXT NOT NULL DEFAULT 'ai',
9
+ model TEXT,
10
+ provider TEXT,
11
+ input_hash TEXT,
12
+ input_summary TEXT,
13
+ output_json TEXT NOT NULL,
14
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
15
+ )`);
16
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_tpai_proposal ON task_proposal_ai_suggestions(proposal_id, created_at DESC)`);
17
+ }
18
+ /** Read the fields the recommender needs (keeps the route free of direct SQL). */
19
+ export function getProposalLite(db, id) {
20
+ return db.prepare('SELECT id, title, summary, suggested_area, source_ref, expected_outcome FROM task_proposals WHERE id = ?').get(id) ?? null;
21
+ }
22
+ const CATEGORY_KEYWORDS = {
23
+ docs: ['doc', 'readme', '文档', 'guide'], i18n: ['i18n', 'translat', '翻译', 'locale'],
24
+ tests: ['test', '测试', 'spec'], ui: ['ui', 'page', 'button', '界面', 'pwa', 'css'],
25
+ api: ['api', 'endpoint', 'route'], schema: ['schema', 'migration', 'table', '字段'],
26
+ infra: ['ci', 'deploy', 'infra', 'pipeline', 'docker'], governance: ['governance', 'charter', '治理', 'rfc'],
27
+ audit: ['audit', 'security', '审计', '安全'], code: ['fix', 'bug', 'refactor', 'implement', '实现', '修复'],
28
+ };
29
+ const HIGH_RISK_KEYWORDS = ['wallet', 'withdraw', 'fund', 'money', 'settle', 'escrow', 'payment', 'reward', 'schema', 'migration', 'auth', 'admin', 'key', '资金', '钱包', '提现', '结算', '权限', '密钥'];
30
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9一-鿿]+/g, ' ').trim();
31
+ /**
32
+ * Deterministic local heuristic recommendation (stub for a future model). Read-only; never decides.
33
+ */
34
+ export function recommendForProposal(db, p) {
35
+ const text = norm(`${p.title} ${p.summary} ${p.suggested_area ?? ''}`);
36
+ // category
37
+ let category = 'other';
38
+ let best = 0;
39
+ for (const [cat, kws] of Object.entries(CATEGORY_KEYWORDS)) {
40
+ const hits = kws.filter(k => text.includes(k)).length;
41
+ if (hits > best) {
42
+ best = hits;
43
+ category = cat;
44
+ }
45
+ }
46
+ // risk
47
+ const risk = HIGH_RISK_KEYWORDS.some(k => text.includes(k)) ? 'high' : (p.summary.length > 400 ? 'medium' : 'low');
48
+ // effort by summary size
49
+ const effort = p.summary.length > 600 ? 'large' : p.summary.length > 200 ? 'medium' : 'small';
50
+ // missing info flags
51
+ const missing_info = [];
52
+ if (p.summary.trim().length < 40)
53
+ missing_info.push('summary too short — needs concrete scope / acceptance');
54
+ if (!p.source_ref)
55
+ missing_info.push('no source_ref (file / RFC / issue reference)');
56
+ if (!p.expected_outcome)
57
+ missing_info.push('no expected_outcome (definition of done)');
58
+ // duplicate likelihood: normalized-title overlap vs existing proposals + build_tasks
59
+ const titleNorm = norm(p.title);
60
+ const titleWords = new Set(titleNorm.split(' ').filter(w => w.length >= 3));
61
+ let dupScore = 0;
62
+ if (titleWords.size > 0) {
63
+ const others = db.prepare(`SELECT title FROM task_proposals WHERE id != ? UNION ALL SELECT title FROM build_tasks`).all(p.id);
64
+ for (const o of others) {
65
+ const ow = new Set(norm(o.title).split(' ').filter(w => w.length >= 3));
66
+ let inter = 0;
67
+ for (const w of titleWords)
68
+ if (ow.has(w))
69
+ inter++;
70
+ const jac = inter / (titleWords.size + ow.size - inter || 1);
71
+ if (jac > dupScore)
72
+ dupScore = jac;
73
+ }
74
+ }
75
+ const duplicate_likelihood = dupScore >= 0.6 ? 'high' : dupScore >= 0.3 ? 'medium' : 'low';
76
+ const recommendation = {
77
+ category, risk, effort, missing_info, duplicate_likelihood,
78
+ suggested: {
79
+ title: p.title,
80
+ area: p.suggested_area ?? category,
81
+ description: p.summary,
82
+ acceptance_criteria: p.expected_outcome ? [String(p.expected_outcome).slice(0, 500)] : [],
83
+ verification_commands: [],
84
+ },
85
+ };
86
+ return { recommendation, model: 'heuristic-v1', provider: 'local' };
87
+ }
88
+ /** Persist an AI suggestion as evidence (accountability metadata). Returns the row id. */
89
+ export function insertAiSuggestion(db, args) {
90
+ const id = generateId('tpai');
91
+ const inputHash = createHash('sha256').update(args.inputSummary).digest('hex');
92
+ db.prepare(`INSERT INTO task_proposal_ai_suggestions (id, proposal_id, reviewer_type, model, provider, input_hash, input_summary, output_json)
93
+ VALUES (?,?,?,?,?,?,?,?)`).run(id, args.proposalId, args.reviewerType ?? 'ai', args.model ?? null, args.provider ?? null, inputHash, args.inputSummary.slice(0, 1000), args.outputJson);
94
+ return { id };
95
+ }
96
+ export function listAiSuggestions(db, proposalId) {
97
+ return db.prepare(`SELECT id, proposal_id, reviewer_type, model, provider, input_summary, output_json, created_at
98
+ FROM task_proposal_ai_suggestions WHERE proposal_id = ? ORDER BY created_at DESC LIMIT 20`).all(proposalId);
99
+ }
@@ -0,0 +1,360 @@
1
+ import { createBuildTask, logTaskEvent, releaseExpiredClaims } from './build-tasks-engine.js';
2
+ import { insertBuildTaskAgentMetadata, getBuildTaskAgentMetadata, setBuildTaskAudience, setBuildTaskEstimate, RISK_LEVELS, TASK_TYPES, CONTEXT_SIZES, AGENT_BUDGETS } from './build-task-agent-metadata-store.js';
3
+ import { reviewTaskProposal } from './task-proposal-store.js';
4
+ /** Source-proposal ↔ draft-task link (lets a draft preserve its origin WITHOUT marking the proposal terminal). */
5
+ export function initTaskProposalDraftLinkSchema(db) {
6
+ db.exec(`CREATE TABLE IF NOT EXISTS task_proposal_draft_links (
7
+ task_id TEXT PRIMARY KEY,
8
+ proposal_id TEXT NOT NULL,
9
+ created_by TEXT NOT NULL,
10
+ status TEXT NOT NULL DEFAULT 'active',
11
+ discarded_by TEXT,
12
+ discarded_at TEXT,
13
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
14
+ )`);
15
+ // Additive migration for pre-existing DBs (ALTER after CREATE; fresh DBs already have these columns).
16
+ // status = 'active' | 'discarded'. A discarded link is SOFT-deleted: the row is retained for provenance
17
+ // (retroactive reward / anchor-side traceability / dispute), and it frees the proposal's draft slot.
18
+ try {
19
+ const cols = db.prepare('PRAGMA table_info(task_proposal_draft_links)').all();
20
+ if (!cols.some(c => c.name === 'status'))
21
+ db.exec("ALTER TABLE task_proposal_draft_links ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
22
+ if (!cols.some(c => c.name === 'discarded_by'))
23
+ db.exec('ALTER TABLE task_proposal_draft_links ADD COLUMN discarded_by TEXT');
24
+ if (!cols.some(c => c.name === 'discarded_at'))
25
+ db.exec('ALTER TABLE task_proposal_draft_links ADD COLUMN discarded_at TEXT');
26
+ }
27
+ catch { /* best-effort additive migration */ }
28
+ // PARTIAL UNIQUE: at most one ACTIVE draft per proposal (discarded links are retained but excluded), so a
29
+ // discarded draft truly frees the slot. Replaces the old full unique index on proposal_id.
30
+ try {
31
+ db.exec('DROP INDEX IF EXISTS idx_tpdl_proposal');
32
+ }
33
+ catch { /* noop */ }
34
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_tpdl_proposal_active ON task_proposal_draft_links(proposal_id) WHERE status = 'active'");
35
+ }
36
+ /**
37
+ * case_id threads a case end to end: proposal → task → PR. For a task converted from a proposal it is that
38
+ * source proposal id (so proposer + contributor + PR all quote the same id); for a directly-created task it
39
+ * is the task id itself. Guarded: the link table may be absent in minimal setups → fall back to the task id.
40
+ */
41
+ export function caseIdForTask(db, taskId) {
42
+ try {
43
+ const link = db.prepare('SELECT proposal_id FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
44
+ if (link?.proposal_id)
45
+ return link.proposal_id;
46
+ }
47
+ catch { /* link table absent → case_id is the task id */ }
48
+ return taskId;
49
+ }
50
+ const strList = (v) => Array.isArray(v) ? v.slice(0, 50).map(s => String(s).slice(0, 500)).filter(s => s.trim()) : [];
51
+ const txt = (v) => (v ?? '').trim();
52
+ /** Handoff fields an executable agent task must carry. Returns the list of missing ones (empty = complete). */
53
+ function missingHandoff(f) {
54
+ const missing = [];
55
+ if (!txt(f.title))
56
+ missing.push('title');
57
+ if (!txt(f.description))
58
+ missing.push('summary/description (reason)');
59
+ if (f.allowed_paths.length === 0)
60
+ missing.push('allowed_paths (execution boundary)');
61
+ if (f.prohibited_actions.length === 0)
62
+ missing.push('forbidden_actions');
63
+ if (f.acceptance_criteria.length === 0)
64
+ missing.push('acceptance_criteria');
65
+ if (f.verification_commands.length === 0)
66
+ missing.push('verification_commands');
67
+ if (f.deliverables.length === 0)
68
+ missing.push('deliverables');
69
+ if (!txt(f.expected_results))
70
+ missing.push('expected_results');
71
+ if (!txt(f.definition_of_done))
72
+ missing.push('definition_of_done');
73
+ return missing;
74
+ }
75
+ /**
76
+ * Create an UNPUBLISHED draft (internal build_task) from a non-terminal proposal and record the
77
+ * source-proposal ↔ draft link. Does NOT mark the proposal 'converted' (acceptance happens at publish).
78
+ * Returns the new draft task id.
79
+ */
80
+ export function createDraftFromProposal(db, a) {
81
+ // 1) proposal must exist + be non-terminal (only new / needs_info → draft). Pre-check before creating anything.
82
+ const prop = db.prepare('SELECT id, status FROM task_proposals WHERE id = ?').get(a.proposalId);
83
+ if (!prop)
84
+ return { error: 'proposal not found', error_code: 'PROPOSAL_NOT_FOUND' };
85
+ if (prop.status === 'rejected' || prop.status === 'converted')
86
+ return { error: `proposal already ${prop.status}`, error_code: 'PROPOSAL_TERMINAL' };
87
+ // one ACTIVE draft per proposal (a discarded draft is soft-deleted and frees the slot; not terminal-status based)
88
+ const existingLink = db.prepare("SELECT task_id FROM task_proposal_draft_links WHERE proposal_id = ? AND status != 'discarded'").get(a.proposalId);
89
+ if (existingLink)
90
+ return { error: `a task draft already exists for this proposal (${existingLink.task_id})`, error_code: 'PROPOSAL_HAS_DRAFT' };
91
+ // draft risk stays low/medium (high/critical metadata rules are out of scope for this PR)
92
+ const riskLevel = (a.riskLevel === 'medium') ? 'medium' : 'low';
93
+ if (a.riskLevel && !RISK_LEVELS.includes(a.riskLevel))
94
+ return { error: 'invalid risk_level', error_code: 'BAD_RISK_LEVEL' };
95
+ if (a.riskLevel === 'high' || a.riskLevel === 'critical')
96
+ return { error: 'draft risk_level must be low or medium (high/critical deferred)', error_code: 'RISK_TOO_HIGH_FOR_DRAFT' };
97
+ const taskType = (a.taskType && TASK_TYPES.includes(a.taskType)) ? a.taskType : 'other';
98
+ // (a) #34/#5: an OPTIONAL real effort estimate. Provided → validated + stored; omitted → 0–0 placeholder
99
+ // (draft stays internal/unclaimable, and publishDraftBuildTask fails closed until a real estimate exists).
100
+ let durMin = 0, durMax = 0;
101
+ if (a.estimatedDurationMinMinutes !== undefined || a.estimatedDurationMaxMinutes !== undefined) {
102
+ const mn = Number(a.estimatedDurationMinMinutes), mx = Number(a.estimatedDurationMaxMinutes);
103
+ if (!Number.isInteger(mn) || !Number.isInteger(mx) || mn < 0 || mx < mn || mx < 1)
104
+ return { error: 'estimated_duration must be integer minutes with max >= min and max >= 1 (a real, non-zero estimate)', error_code: 'BAD_ESTIMATE' };
105
+ durMin = mn;
106
+ durMax = mx;
107
+ }
108
+ if (a.estimatedAgentBudget !== undefined && !AGENT_BUDGETS.includes(a.estimatedAgentBudget))
109
+ return { error: 'invalid estimated_agent_budget', error_code: 'BAD_AGENT_BUDGET' };
110
+ if (a.estimatedContextSize !== undefined && !CONTEXT_SIZES.includes(a.estimatedContextSize))
111
+ return { error: 'invalid estimated_context_size', error_code: 'BAD_CONTEXT_SIZE' };
112
+ // 2) assemble the agent-handoff fields and VALIDATE completeness BEFORE creating anything — the existing
113
+ // task model requires these to be executable, so we never create an incomplete/orphan task.
114
+ const allowed = strList(a.allowedPaths);
115
+ const prohibited = strList(a.forbiddenActions);
116
+ const accept = strList(a.acceptanceCriteria);
117
+ const verify = strList(a.verificationCommands);
118
+ const deliver = strList(a.deliverables);
119
+ const description = txt(a.description);
120
+ const dod = txt(a.definitionOfDone).slice(0, 1000);
121
+ const expected = (txt(a.expectedResults) || description).slice(0, 1000);
122
+ const missing = missingHandoff({ title: txt(a.title), description, allowed_paths: allowed, prohibited_actions: prohibited, acceptance_criteria: accept, verification_commands: verify, deliverables: deliver, expected_results: expected, definition_of_done: dod });
123
+ if (missing.length > 0)
124
+ return { error: `draft incomplete — provide: ${missing.join(', ')}`, error_code: 'DRAFT_INCOMPLETE', missing };
125
+ // 3) create the formal task via the trusted path (creator = admin → accountability)
126
+ const created = createBuildTask(db, { creatorId: a.adminId, title: a.title, area: a.area ?? undefined, description: a.description ?? undefined, rfcRef: a.sourceRef ?? undefined });
127
+ if ('error' in created)
128
+ return created;
129
+ const taskId = created.id;
130
+ // 4) attach agent_metadata as an INTERNAL draft (hidden + unclaimable via the existing read/participation
131
+ // guards). auto_claimable defaults TRUE so once published the task enters the normal agent claim flow
132
+ // (maintainer may opt human-only with autoClaimable:false). While audience='internal' it is unclaimable.
133
+ const caps = strList(a.requiredCapabilities);
134
+ if (caps.length === 0)
135
+ caps.push('general');
136
+ const meta = {
137
+ task_type: taskType,
138
+ source_ref: a.sourceRef ?? null,
139
+ allowed_paths: allowed,
140
+ forbidden_paths: strList(a.forbiddenPaths),
141
+ prohibited_actions: prohibited,
142
+ risk_level: riskLevel,
143
+ audience: 'internal',
144
+ agent_autonomy: 'human_in_the_loop',
145
+ auto_claimable: a.autoClaimable !== false,
146
+ required_capabilities: caps,
147
+ acceptance_criteria: accept,
148
+ verification_commands: verify,
149
+ expected_results: expected,
150
+ deliverables: deliver,
151
+ definition_of_done: dod,
152
+ estimated_duration_min_minutes: durMin,
153
+ estimated_duration_max_minutes: durMax,
154
+ estimated_context_size: a.estimatedContextSize ?? 'small',
155
+ estimated_agent_budget: a.estimatedAgentBudget ?? 'minimal',
156
+ value_state: 'uncommitted',
157
+ contribution_type: 'task',
158
+ accountable_party_required: true,
159
+ };
160
+ insertBuildTaskAgentMetadata(db, taskId, meta);
161
+ // 5) record the source-proposal ↔ draft link (accountability) — WITHOUT marking the proposal converted;
162
+ // the proposal stays non-terminal until an explicit human publish.
163
+ db.prepare('INSERT INTO task_proposal_draft_links (task_id, proposal_id, created_by) VALUES (?,?,?)').run(taskId, a.proposalId, a.adminId);
164
+ return { draft_task_id: taskId };
165
+ }
166
+ /** Admin list of UNPUBLISHED drafts (internal, open) + their source proposal id (via the draft-link table). */
167
+ export function listDraftBuildTasks(db) {
168
+ return db.prepare(`
169
+ SELECT t.id, t.title, t.area, t.description, t.rfc_ref, t.status, t.created_by, t.created_at,
170
+ m.risk_level, m.audience, m.auto_claimable,
171
+ l.proposal_id AS source_proposal_id
172
+ FROM build_tasks t
173
+ JOIN build_task_agent_metadata m ON m.task_id = t.id
174
+ LEFT JOIN task_proposal_draft_links l ON l.task_id = t.id
175
+ WHERE m.audience = 'internal' AND t.status = 'open' AND (l.status IS NULL OR l.status != 'discarded')
176
+ ORDER BY t.created_at DESC LIMIT 200
177
+ `).all();
178
+ }
179
+ /**
180
+ * Validate that a draft carries enough agent-handoff info to be executed by another participant's agent.
181
+ * Returns the list of missing fields (empty = complete). Gate for publish — never publish an incomplete task.
182
+ */
183
+ export function validateDraftForPublish(task, meta) {
184
+ return missingHandoff({
185
+ title: txt(task.title), description: txt(task.description),
186
+ allowed_paths: meta.allowed_paths ?? [], prohibited_actions: meta.prohibited_actions ?? [],
187
+ acceptance_criteria: meta.acceptance_criteria ?? [], verification_commands: meta.verification_commands ?? [],
188
+ deliverables: meta.deliverables ?? [], expected_results: txt(meta.expected_results), definition_of_done: txt(meta.definition_of_done),
189
+ });
190
+ }
191
+ /**
192
+ * PUBLISH a draft → audience 'public' (now on the board / claimable via the existing flow). Explicit
193
+ * human/admin action only; validates agent-handoff completeness first (never publishes an incomplete task).
194
+ * This is also where the source proposal is marked 'converted' (converted_ref=task id, reviewer=actor) —
195
+ * acceptance happens at publish, not at draft creation. The canonical repo / PR target is the protocol-wide
196
+ * canonical contribution target enforced by the existing submit path — it is not stored per-task.
197
+ *
198
+ * Evidence-chain guard: the linked proposal is validated BEFORE anything is published. If it was rejected (or
199
+ * converted to a different task) since draft creation, publish is refused (409) and the task stays internal —
200
+ * so a public, claimable task can never coexist with a rejected source proposal. The audience flip + proposal
201
+ * conversion run in one transaction (both or neither).
202
+ */
203
+ export function publishDraftBuildTask(db, taskId, adminId, estimate) {
204
+ const meta = getBuildTaskAgentMetadata(db, taskId);
205
+ if (!meta)
206
+ return { error: 'task has no draft metadata', error_code: 'NOT_A_DRAFT' };
207
+ if (meta.audience !== 'internal')
208
+ return { error: 'task is not an internal draft', error_code: 'NOT_DRAFT_AUDIENCE' };
209
+ const t = db.prepare('SELECT status, title, description FROM build_tasks WHERE id = ?').get(taskId);
210
+ if (!t)
211
+ return { error: 'task not found', error_code: 'NOT_FOUND' };
212
+ const missing = validateDraftForPublish(t, meta);
213
+ if (missing.length > 0)
214
+ return { error: `draft incomplete — fill before publish: ${missing.join(', ')}`, error_code: 'DRAFT_INCOMPLETE', missing };
215
+ // validate the linked proposal FIRST — do not publish on top of a rejected / elsewhere-converted proposal.
216
+ const link = db.prepare('SELECT proposal_id, status FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
217
+ let proposalToConvert = null;
218
+ if (link) {
219
+ if (link.status === 'discarded')
220
+ return { error: 'draft was discarded — cannot publish', error_code: 'DRAFT_DISCARDED' };
221
+ const prop = db.prepare('SELECT status, converted_ref FROM task_proposals WHERE id = ?').get(link.proposal_id);
222
+ if (prop) {
223
+ if (prop.status === 'rejected')
224
+ return { error: 'source proposal was rejected — cannot publish', error_code: 'PROPOSAL_REJECTED' };
225
+ if (prop.status === 'converted' && prop.converted_ref !== taskId)
226
+ return { error: 'source proposal already converted to a different task', error_code: 'PROPOSAL_CONVERTED_ELSEWHERE' };
227
+ if (prop.status === 'new' || prop.status === 'needs_info')
228
+ proposalToConvert = link.proposal_id;
229
+ }
230
+ }
231
+ // (a) #34/#5: FAIL-CLOSED on a placeholder estimate (checked AFTER proposal validity, so a dead/rejected
232
+ // source surfaces its own error first). A published task must carry a REAL effort estimate (non 0–0 /
233
+ // non-null duration), else the claim guard would correctly refuse it as manual_review forever ("published
234
+ // but unclaimable"). The maintainer may supply the estimate here; otherwise the draft's stored estimate must
235
+ // already be real. The override is validated + persisted inside the publish transaction below.
236
+ const overrideEstimate = !!estimate && (estimate.minMinutes !== undefined || estimate.maxMinutes !== undefined);
237
+ let effMin = meta.estimated_duration_min_minutes;
238
+ let effMax = meta.estimated_duration_max_minutes;
239
+ if (overrideEstimate) {
240
+ const mn = Number(estimate.minMinutes), mx = Number(estimate.maxMinutes);
241
+ if (!Number.isInteger(mn) || !Number.isInteger(mx) || mn < 0 || mx < mn || mx < 1)
242
+ return { error: 'estimated_duration must be integer minutes with max >= min and max >= 1 (a real, non-zero estimate)', error_code: 'BAD_ESTIMATE' };
243
+ effMin = mn;
244
+ effMax = mx;
245
+ }
246
+ if (estimate?.budget !== undefined && !AGENT_BUDGETS.includes(estimate.budget))
247
+ return { error: 'invalid estimated_agent_budget', error_code: 'BAD_AGENT_BUDGET' };
248
+ if (estimate?.contextSize !== undefined && !CONTEXT_SIZES.includes(estimate.contextSize))
249
+ return { error: 'invalid estimated_context_size', error_code: 'BAD_CONTEXT_SIZE' };
250
+ const estimateReal = effMin != null && effMax != null && !(effMin === 0 && effMax === 0);
251
+ if (!estimateReal)
252
+ return { error: 'a real effort estimate is required before publishing (the draft still has the 0–0 placeholder); supply estimate {minMinutes, maxMinutes}', error_code: 'DRAFT_ESTIMATE_REQUIRED' };
253
+ // all validation passed → publish atomically: persist any estimate override + flip audience + (if
254
+ // applicable) mark the proposal converted. All-or-nothing.
255
+ db.transaction(() => {
256
+ if (overrideEstimate || estimate?.budget !== undefined || estimate?.contextSize !== undefined) {
257
+ setBuildTaskEstimate(db, taskId, { minMinutes: effMin, maxMinutes: effMax, budget: estimate?.budget, contextSize: estimate?.contextSize });
258
+ }
259
+ setBuildTaskAudience(db, taskId, 'public');
260
+ logTaskEvent(db, taskId, adminId, t.status, t.status, 'published from draft (audience → public)');
261
+ if (proposalToConvert) {
262
+ const rv = reviewTaskProposal(db, proposalToConvert, adminId, 'converted', `published as formal task ${taskId}`, taskId);
263
+ if ('error' in rv)
264
+ throw new Error(`proposal conversion failed: ${rv.code}`); // rollback the audience flip
265
+ }
266
+ })();
267
+ return { ok: true, task_id: taskId };
268
+ }
269
+ /**
270
+ * DISCARD an unpublished internal draft — SOFT-delete (status='discarded'); the link row is RETAINED for
271
+ * provenance (retroactive reward / anchor-side traceability / dispute). Discarding frees the proposal's draft
272
+ * slot (createDraftFromProposal counts only non-discarded links), so a fresh draft can be created.
273
+ *
274
+ * Fail-closed: ONLY an internal, unpublished, unclaimed draft may be discarded. A published draft (audience
275
+ * flipped to public), a claimed task, or an already-converted source proposal is REFUSED — discard never
276
+ * touches anything that's live on the board or already accepted. Scope is discard only (NOT a generic edit).
277
+ */
278
+ export function discardDraft(db, taskId, adminId) {
279
+ const link = db.prepare('SELECT proposal_id, status FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
280
+ if (!link)
281
+ return { error: 'no draft link for this task', error_code: 'NOT_FOUND' };
282
+ if (link.status === 'discarded')
283
+ return { ok: true, task_id: taskId, already_discarded: true }; // idempotent
284
+ // fail-closed guards — only an internal, unpublished, unclaimed draft of a non-converted proposal
285
+ const meta = getBuildTaskAgentMetadata(db, taskId);
286
+ if (!meta)
287
+ return { error: 'task has no draft metadata', error_code: 'NOT_A_DRAFT' };
288
+ if (meta.audience !== 'internal')
289
+ return { error: 'task is published (audience is not internal) — cannot discard', error_code: 'ALREADY_PUBLISHED' };
290
+ const t = db.prepare('SELECT status, claimer_id FROM build_tasks WHERE id = ?').get(taskId);
291
+ if (!t)
292
+ return { error: 'task not found', error_code: 'NOT_FOUND' };
293
+ if (t.claimer_id)
294
+ return { error: 'task is claimed — cannot discard', error_code: 'DRAFT_CLAIMED' };
295
+ const prop = db.prepare('SELECT status FROM task_proposals WHERE id = ?').get(link.proposal_id);
296
+ if (prop && prop.status === 'converted')
297
+ return { error: 'source proposal already converted — cannot discard', error_code: 'ALREADY_CONVERTED' };
298
+ db.prepare("UPDATE task_proposal_draft_links SET status = 'discarded', discarded_by = ?, discarded_at = datetime('now') WHERE task_id = ?").run(adminId, taskId);
299
+ return { ok: true, task_id: taskId };
300
+ }
301
+ /**
302
+ * Full stored body of an UNPUBLISHED internal draft — for pre-publish PREVIEW so a maintainer publishes
303
+ * against the exact content that will go live (not a blind button). Returns null unless the task is an
304
+ * internal-audience draft (getBuildTaskWithAgentMetadata's member/public scopes deliberately hide internal,
305
+ * so this admin-only read exists). Read-only — does not change publish behavior.
306
+ */
307
+ export function getDraftBuildTaskDetail(db, taskId) {
308
+ const meta = getBuildTaskAgentMetadata(db, taskId);
309
+ if (!meta || meta.audience !== 'internal')
310
+ return null; // only unpublished internal drafts are previewable here
311
+ const t = db.prepare('SELECT id, title, area, description, rfc_ref, status, created_by, created_at FROM build_tasks WHERE id = ?').get(taskId);
312
+ if (!t)
313
+ return null;
314
+ const link = db.prepare('SELECT proposal_id, status FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
315
+ return {
316
+ ...t,
317
+ source_proposal_id: link?.proposal_id ?? null,
318
+ draft_link_status: link?.status ?? null,
319
+ agent_metadata: meta, // full body: allowed/forbidden paths, prohibited_actions, acceptance_criteria, verification_commands, deliverables, definition_of_done, expected_results, risk_level, auto_claimable, …
320
+ };
321
+ }
322
+ /**
323
+ * Recovery: WITHDRAW a published task that was converted from a proposal — pull it off the public board and
324
+ * REOPEN the source proposal so a corrected draft can be built. Fail-closed: only an UNCLAIMED, open,
325
+ * published task (audience='public') may be withdrawn (a claimed / in_review / done task is refused).
326
+ *
327
+ * Soft-delete semantics (provenance retained): the build_task is set status='abandoned' (off the board, row
328
+ * kept), the draft link is marked 'discarded' (kept, frees the slot), and the proposal is un-converted
329
+ * (status='new', converted_ref cleared) so createDraftFromProposal works again. Scope = recovery only.
330
+ */
331
+ export function withdrawPublishedTask(db, taskId, adminId) {
332
+ releaseExpiredClaims(db); // an expired claim is effectively unclaimed
333
+ const meta = getBuildTaskAgentMetadata(db, taskId);
334
+ if (!meta)
335
+ return { error: 'task not found', error_code: 'NOT_FOUND' };
336
+ if (meta.audience !== 'public')
337
+ return { error: 'task is not published (use discard for an internal draft)', error_code: 'NOT_PUBLISHED' };
338
+ const t = db.prepare('SELECT status, claimer_id FROM build_tasks WHERE id = ?').get(taskId);
339
+ if (!t)
340
+ return { error: 'task not found', error_code: 'NOT_FOUND' };
341
+ if (t.claimer_id || t.status !== 'open')
342
+ return { error: `task is ${t.status}${t.claimer_id ? ' (claimed)' : ''} — only an UNCLAIMED open task can be withdrawn`, error_code: 'TASK_CLAIMED' };
343
+ const link = db.prepare('SELECT proposal_id, status FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
344
+ let reopened = null;
345
+ db.transaction(() => {
346
+ db.prepare("UPDATE build_tasks SET status = 'abandoned', updated_at = datetime('now') WHERE id = ?").run(taskId);
347
+ logTaskEvent(db, taskId, adminId, t.status, 'abandoned', 'withdrawn by admin — published task pulled off the board (recovery)');
348
+ if (link) {
349
+ if (link.status !== 'discarded')
350
+ db.prepare("UPDATE task_proposal_draft_links SET status = 'discarded', discarded_by = ?, discarded_at = datetime('now') WHERE task_id = ?").run(adminId, taskId);
351
+ const prop = db.prepare('SELECT status, converted_ref FROM task_proposals WHERE id = ?').get(link.proposal_id);
352
+ if (prop && prop.status === 'converted' && prop.converted_ref === taskId) {
353
+ db.prepare("UPDATE task_proposals SET status = 'new', converted_ref = NULL, reviewer_id = NULL, review_note = ?, updated_at = datetime('now') WHERE id = ?")
354
+ .run(`reopened: published task ${taskId} withdrawn by admin`, link.proposal_id);
355
+ reopened = link.proposal_id;
356
+ }
357
+ }
358
+ })();
359
+ return { ok: true, task_id: taskId, reopened_proposal_id: reopened };
360
+ }
@@ -15,6 +15,7 @@ const CREATE_TABLE = `
15
15
  status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','needs_info','rejected','converted')),
16
16
  reviewer_id TEXT,
17
17
  review_note TEXT CHECK (review_note IS NULL OR length(review_note) <= 2000),
18
+ public_reply TEXT CHECK (public_reply IS NULL OR length(public_reply) <= 2000),
18
19
  converted_ref TEXT CHECK (converted_ref IS NULL OR length(converted_ref) <= 500),
19
20
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
20
21
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -24,6 +25,14 @@ const CREATE_INDEX = `CREATE INDEX IF NOT EXISTS idx_task_proposals_status ON ta
24
25
  export function initTaskProposalSchema(db) {
25
26
  db.exec(CREATE_TABLE);
26
27
  db.exec(CREATE_INDEX);
28
+ // Additive migration for pre-existing DBs (ALTER after CREATE; fresh DBs already have the column via CREATE_TABLE).
29
+ // public_reply = the proposer-facing reply (distinct from the admin-internal review_note).
30
+ try {
31
+ const cols = db.prepare('PRAGMA table_info(task_proposals)').all();
32
+ if (!cols.some(c => c.name === 'public_reply'))
33
+ db.exec('ALTER TABLE task_proposals ADD COLUMN public_reply TEXT');
34
+ }
35
+ catch { /* best-effort additive migration */ }
27
36
  }
28
37
  /** Validate a public submission — fail-closed: bad/oversized fields are rejected, never silently truncated. */
29
38
  export function validateProposalInput(body) {
@@ -102,9 +111,21 @@ export function listTaskProposals(db, filter = {}) {
102
111
  params.push(filter.status);
103
112
  }
104
113
  return db.prepare(`SELECT id, title, summary, suggested_area, expected_outcome, source_ref, proposer_account_id,
105
- proposer_github_login, status, reviewer_id, review_note, converted_ref, created_at, updated_at FROM task_proposals
114
+ proposer_github_login, status, reviewer_id, review_note, public_reply, converted_ref, created_at, updated_at FROM task_proposals
106
115
  ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY created_at DESC LIMIT 200`).all(...params);
107
116
  }
117
+ /**
118
+ * Proposer-facing read: returns ONLY the caller's own proposals, and ONLY proposer-safe fields.
119
+ * Deliberately excludes the admin-internal `review_note`, `reviewer_id`, and never returns other users' rows
120
+ * (least-privilege; the proposer sees status + the proposer-facing `public_reply` + converted_ref).
121
+ */
122
+ export function listMyProposals(db, accountId) {
123
+ if (!accountId)
124
+ return [];
125
+ return db.prepare(`SELECT id, title, summary, suggested_area, expected_outcome, source_ref, status,
126
+ public_reply, converted_ref, created_at, updated_at FROM task_proposals
127
+ WHERE proposer_account_id = ? ORDER BY created_at DESC LIMIT 100`).all(accountId);
128
+ }
108
129
  /**
109
130
  * Review a proposal: needs_info / rejected / converted. NO build_task is created here (conversion is a
110
131
  * later manual maintainer step — deferred). Terminal states (rejected/converted) cannot be re-reviewed.
@@ -112,18 +133,22 @@ export function listTaskProposals(db, filter = {}) {
112
133
  * product decision — the non-code contribution evidence chain { proposer → reviewer → converted_ref }.
113
134
  * Recording only; no reward/score is computed.
114
135
  */
115
- export function reviewTaskProposal(db, id, reviewerId, status, note, convertedRef) {
136
+ export function reviewTaskProposal(db, id, reviewerId, status, note, convertedRef, publicReply) {
116
137
  if (!REVIEW_TARGETS.includes(status))
117
138
  return { error: 'status must be needs_info | rejected | converted', code: 'BAD_STATUS' };
118
139
  if (convertedRef != null && (typeof convertedRef !== 'string' || convertedRef.length > CONVERTED_REF_MAX))
119
140
  return { error: 'converted_ref invalid/too long', code: 'CONVERTED_REF_TOO_LONG' };
141
+ if (publicReply != null && typeof publicReply !== 'string')
142
+ return { error: 'public_reply must be a string', code: 'PUBLIC_REPLY_INVALID' };
120
143
  const cur = db.prepare('SELECT status FROM task_proposals WHERE id = ?').get(id);
121
144
  if (!cur)
122
145
  return { error: 'proposal not found', code: 'NOT_FOUND' };
123
146
  if (cur.status === 'rejected' || cur.status === 'converted')
124
147
  return { error: `proposal already ${cur.status}`, code: 'ALREADY_TERMINAL' };
125
148
  const ref = status === 'converted' && typeof convertedRef === 'string' && convertedRef.trim() ? convertedRef.trim() : null;
126
- db.prepare(`UPDATE task_proposals SET status = ?, reviewer_id = ?, review_note = ?, converted_ref = ?, updated_at = datetime('now') WHERE id = ?`)
127
- .run(status, reviewerId, note ? String(note).slice(0, NOTE_MAX) : null, ref, id);
149
+ // public_reply: update only when provided (COALESCE keeps a prior reply if this review omits it).
150
+ const reply = publicReply != null ? String(publicReply).slice(0, NOTE_MAX) : null;
151
+ db.prepare(`UPDATE task_proposals SET status = ?, reviewer_id = ?, review_note = ?, public_reply = COALESCE(?, public_reply), converted_ref = ?, updated_at = datetime('now') WHERE id = ?`)
152
+ .run(status, reviewerId, note ? String(note).slice(0, NOTE_MAX) : null, reply, ref, id);
128
153
  return { id, status, converted_ref: ref };
129
154
  }
package/dist/ledger.js CHANGED
@@ -42,7 +42,7 @@ export function debitStakeThenBalance(db, userId, amountU) {
42
42
  }
43
43
  /**
44
44
  * 通用基金/池表的【绝对值】入账(整数 base-units):读当前→加 delta→写 toDecimal。
45
- * 用于 global_fund / management_bonus_pool / commission_reserve / charity_fund 等单行池表。
45
+ * 用于 global_fund / protocol_reserve_pool / commission_reserve / charity_fund 等单行池表。
46
46
  * table / 列名 / whereClause 均为【代码字面量】(非用户输入)→ 无 SQL 注入面。
47
47
  * 行不存在 → UPDATE 影响 0 行(与历史相对更新一致)。
48
48
  */
@@ -0,0 +1,38 @@
1
+ import { generateId } from '../layer0-foundation/L0-1-database/schema.js';
2
+ /** Default context for an action logged without explicit accountability — NOT contribution-eligible. */
3
+ function normalizeContext(ctx) {
4
+ return {
5
+ actor_type: ctx?.actorType ?? 'unknown_agent',
6
+ actor_ref: ctx?.actorRef ?? null,
7
+ agent_mode: ctx?.agentMode ?? 'unknown_agent',
8
+ human_authorization_id: ctx?.humanAuthorizationId ?? null,
9
+ mandate_id: ctx?.mandateId ?? null,
10
+ approval_kind: ctx?.approvalKind ?? null,
11
+ conflict_disclosure: ctx?.conflictDisclosure ?? 'unknown',
12
+ provenance: ctx?.provenance ?? null,
13
+ };
14
+ }
15
+ /**
16
+ * Write one admin_audit_log row (with `detail._ctx` accountability context) and return its id. The id
17
+ * is what the coordination ingestion engine references as `admin_audit_log_id`.
18
+ */
19
+ export function logAdminAction(db, input) {
20
+ const id = generateId('audit');
21
+ const detail = JSON.stringify({ ...(input.detail ?? {}), _ctx: normalizeContext(input.context) });
22
+ db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail) VALUES (?,?,?,?,?,?)`)
23
+ .run(id, input.adminId, input.action, input.targetType ?? null, input.targetId ?? null, detail);
24
+ return id;
25
+ }
26
+ /** Read back the accountability context of an audit row. Absent `_ctx` → unknown / not eligible. */
27
+ export function readAdminActionContext(detailJson) {
28
+ if (!detailJson)
29
+ return normalizeContext();
30
+ try {
31
+ const parsed = JSON.parse(detailJson);
32
+ const ctx = parsed && typeof parsed === 'object' ? parsed._ctx : undefined;
33
+ return ctx && typeof ctx === 'object' ? ctx : normalizeContext();
34
+ }
35
+ catch {
36
+ return normalizeContext();
37
+ }
38
+ }
@@ -0,0 +1,21 @@
1
+ export function resolveBearerProtocolAdmin(db, req, isProtocolAdmin) {
2
+ const authz = req?.headers?.authorization;
3
+ if (typeof authz !== 'string' || !authz.startsWith('Bearer '))
4
+ return null; // Bearer only — NOT req.body.api_key
5
+ const key = authz.slice('Bearer '.length).trim();
6
+ if (!key)
7
+ return null;
8
+ const u = db.prepare('SELECT * FROM users WHERE api_key = ?').get(key);
9
+ if (!u)
10
+ return null;
11
+ const mod = db.prepare('SELECT suspended FROM user_moderation WHERE user_id = ?').get(u.id);
12
+ if (mod?.suspended)
13
+ return null; // suspended admin cannot approve
14
+ const session = db.prepare('SELECT revoked_at FROM user_sessions WHERE api_key = ? ORDER BY created_at DESC LIMIT 1')
15
+ .get(key);
16
+ if (session?.revoked_at)
17
+ return null; // remotely logged-out session cannot approve
18
+ if (!isProtocolAdmin(u))
19
+ return null; // admin role + protocol permission (caller-supplied, central logic)
20
+ return u;
21
+ }