@stravica-ai/digital-operator-pa 0.1.20 → 0.1.21
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/engineering-pa-cli/bin/estate-sync +0 -0
- package/dist/engineering-pa-cli/bin/launch +0 -0
- package/dist/engineering-pa-cli/bin/pa +0 -0
- package/dist/engineering-pa-cli/bin/pa-coordinator-hook +0 -0
- package/dist/engineering-pa-cli/bin/research +0 -0
- package/dist/engineering-pa-cli/bin/scheduler +0 -0
- package/dist/engineering-pa-cli/bin/secrets +0 -0
- package/dist/engineering-pa-cli/bin/smoke +0 -0
- package/dist/engineering-pa-cli/bin/sync +0 -0
- package/dist/engineering-pa-cli/scripts/migrate-doc-paths.mjs +0 -0
- package/dist/engineering-pa-cli/src/pa/work-cli.mjs +36 -1
- package/dist/engineering-pa-cli/src/pa/work.mjs +67 -27
- package/dist/engineering-pa-core/bin/desk +0 -0
- package/dist/engineering-pa-core/bin/distill +0 -0
- package/dist/engineering-pa-core/bin/memory-sync +0 -0
- package/dist/engineering-pa-core/bin/slice +0 -0
- package/dist/engineering-pa-core/schema/work-item.json +2 -2
- package/dist/engineering-pa-core/src/workItemId/index.mjs +219 -0
- package/dist/engineering-pa-migrate-inspect/bin/migrate-inspect +0 -0
- package/dist/engineering-pa-scaffold/bin/pa-init +0 -0
- package/dist/engineering-pa-workspace/src/ws.mjs +0 -0
- package/package.json +6 -5
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
// work-cli.mjs — argv router for `pa work <verb>`.
|
|
2
2
|
//
|
|
3
3
|
// Verbs:
|
|
4
|
-
// pa work create [--project <id>] --title "..." --category <c> [--priority N] [--summary "..."] [--owner ...] [--assignee ...]
|
|
4
|
+
// pa work create [--project <id>] --title "..." --category <c> [--priority N] [--summary "..."] [--owner ...] [--assignee ...] [--author <token>]
|
|
5
5
|
// pa work status <id> <status>
|
|
6
6
|
// pa work list [--project <id>] [--status ...] [--mine] [--theirs] [--all] [--json]
|
|
7
7
|
//
|
|
8
8
|
// PA name (for --mine resolution) is read from
|
|
9
9
|
// operator/config/preferences.json. Falls back to 'pa' if missing.
|
|
10
|
+
//
|
|
11
|
+
// The `paName` field ALSO doubles as the fallback author-token source
|
|
12
|
+
// for `pa work create` (writer-scoped id allocation, R1 of the
|
|
13
|
+
// 2026-08-11 library remediation train). Precedence, highest first:
|
|
14
|
+
// 1. --author flag (or opts.author from a programmatic caller)
|
|
15
|
+
// 2. PA_AUTHOR_TOKEN env var
|
|
16
|
+
// 3. preferences.json `paName`
|
|
17
|
+
// If none resolve, `pa work create` hard-errors — the whole point of
|
|
18
|
+
// R1 is that a mint with no author identity is a defect, not a
|
|
19
|
+
// fall-back-to-default case. Full rationale + tests in
|
|
20
|
+
// `@stravica-ai/engineering-pa-core/src/workItemId/index.mjs`.
|
|
10
21
|
|
|
11
22
|
import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
|
|
12
23
|
|
|
@@ -18,6 +29,7 @@ const USAGE = `pa work — manage operator-repo work items
|
|
|
18
29
|
Usage:
|
|
19
30
|
pa work create [--project <id>] --title "..." --category <feature|bug|chore|discussion|research>
|
|
20
31
|
[--priority 0|1|2|3] [--summary "..."] [--owner ...] [--assignee ...]
|
|
32
|
+
[--author <token>]
|
|
21
33
|
pa work status <id> <draft|planned|in-progress|blocked|done|archived>
|
|
22
34
|
pa work list [--project <id>] [--status ...] [--category ...] [--mine] [--theirs] [--all] [--json]
|
|
23
35
|
pa work -h, --help
|
|
@@ -26,6 +38,9 @@ Notes:
|
|
|
26
38
|
- status=done stamps \`completedAt\` to today (only if not already set).
|
|
27
39
|
- --mine resolves the PA name from operator/config/preferences.json
|
|
28
40
|
(paName / agentName). Default: "pa".
|
|
41
|
+
- --author sets the writer-scoped id's author token. Precedence:
|
|
42
|
+
--author > PA_AUTHOR_TOKEN env > preferences.json paName. Must
|
|
43
|
+
match /^[a-z]{2,12}$/ and be present in operator/config/authors.json.
|
|
29
44
|
|
|
30
45
|
Exit codes:
|
|
31
46
|
0 ok | 1 usage/not-found | 2 operational failure
|
|
@@ -89,11 +104,29 @@ async function getPaName() {
|
|
|
89
104
|
}
|
|
90
105
|
}
|
|
91
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Read the raw `paName` (device-local overlay merged over shared). Unlike
|
|
109
|
+
* `getPaName`, this returns null if the field is unset — createWork's
|
|
110
|
+
* author-resolver treats null as "skip this source", not "fall back to
|
|
111
|
+
* literal 'pa'" (which would silently mask a missing token).
|
|
112
|
+
*/
|
|
113
|
+
async function readPreferencesPaName() {
|
|
114
|
+
try {
|
|
115
|
+
const repoRoot = await getRepoRoot();
|
|
116
|
+
const config = await readPreferences(repoRoot);
|
|
117
|
+
if (!config) return null;
|
|
118
|
+
return config.paName || config.agentName || null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
92
124
|
async function doCreate(args) {
|
|
93
125
|
const { flags } = parseFlags(args);
|
|
94
126
|
const priority = flags.priority != null && flags.priority !== true
|
|
95
127
|
? Number(flags.priority)
|
|
96
128
|
: null;
|
|
129
|
+
const preferencesPaName = await readPreferencesPaName();
|
|
97
130
|
const res = await createWork({
|
|
98
131
|
projectId: typeof flags.project === 'string' ? flags.project : null,
|
|
99
132
|
title: typeof flags.title === 'string' ? flags.title : null,
|
|
@@ -102,6 +135,8 @@ async function doCreate(args) {
|
|
|
102
135
|
summary: typeof flags.summary === 'string' ? flags.summary : null,
|
|
103
136
|
owner: typeof flags.owner === 'string' ? flags.owner : null,
|
|
104
137
|
assignee: typeof flags.assignee === 'string' ? flags.assignee : null,
|
|
138
|
+
author: typeof flags.author === 'string' ? flags.author : null,
|
|
139
|
+
preferencesPaName,
|
|
105
140
|
});
|
|
106
141
|
process.stdout.write(`created ${res.id}\n`);
|
|
107
142
|
process.stdout.write(` ${res.path}\n`);
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
// work.mjs — write-side commands for `pa work <verb>`.
|
|
2
2
|
//
|
|
3
|
-
// Source of truth is work/w-YYYY-MM-DD
|
|
3
|
+
// Source of truth is work/w-YYYY-MM-DD-*.json. We never cache.
|
|
4
4
|
//
|
|
5
5
|
// What lives here:
|
|
6
|
-
// - create: scaffold a new work item with the next-free NNN
|
|
6
|
+
// - create: scaffold a new work item with the next-free NNN in the
|
|
7
|
+
// CURRENT WRITER'S per-day sequence
|
|
7
8
|
// - status: change status (stamps `completedAt` on done)
|
|
8
9
|
// - list: list work items, optionally filtered
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
11
|
+
// Id format, parsing, and author resolution all live in
|
|
12
|
+
// `@stravica-ai/engineering-pa-core/src/workItemId/index.mjs` — the
|
|
13
|
+
// single-source primitive for the whole estate. See its header for the
|
|
14
|
+
// two legal forms (legacy + writer-scoped), the R1 remediation
|
|
15
|
+
// background, and the author-token precedence rules.
|
|
12
16
|
|
|
13
17
|
import { existsSync } from 'node:fs';
|
|
14
18
|
import { join } from 'node:path';
|
|
15
19
|
|
|
20
|
+
import {
|
|
21
|
+
parseWorkItemId,
|
|
22
|
+
isValidWorkItemId,
|
|
23
|
+
buildWorkItemId,
|
|
24
|
+
resolveAuthorToken,
|
|
25
|
+
maxNForAuthorOnDate,
|
|
26
|
+
} from '../../../engineering-pa-core/src/workItemId/index.mjs';
|
|
27
|
+
|
|
16
28
|
import { getRepoRoot } from './repo-root.mjs';
|
|
17
29
|
import { readJson, writeJson, ensureDir, listJsonFiles, today } from './io.mjs';
|
|
18
30
|
|
|
@@ -20,8 +32,6 @@ const STATUSES = new Set(['draft', 'planned', 'in-progress', 'blocked', 'done',
|
|
|
20
32
|
|
|
21
33
|
const CATEGORIES = new Set(['feature', 'bug', 'chore', 'discussion', 'research']);
|
|
22
34
|
|
|
23
|
-
const ID_RE = /^w-(\d{4}-\d{2}-\d{2})-(\d{3})$/;
|
|
24
|
-
|
|
25
35
|
function validateStatus(status) {
|
|
26
36
|
if (!STATUSES.has(status)) {
|
|
27
37
|
const err = new Error(
|
|
@@ -53,38 +63,44 @@ function validatePriority(priority) {
|
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
66
|
+
* Highest NNN already allocated for `dateStr` in THIS author's per-day
|
|
67
|
+
* sequence. Legacy items (`w-YYYY-MM-DD-NNN` with no author segment)
|
|
68
|
+
* do not count toward any author's sequence — each writer starts from
|
|
69
|
+
* 001 on the day writer-scoping takes effect and existing legacy items
|
|
70
|
+
* simply occupy a separate, older space.
|
|
58
71
|
*
|
|
59
72
|
* If 999 is already taken, throws — callers should treat as an
|
|
60
|
-
* operational failure and report. In practice this is unreachable
|
|
73
|
+
* operational failure and report. In practice this is unreachable
|
|
74
|
+
* because NNN is per-author-per-day, not shared.
|
|
61
75
|
*/
|
|
62
|
-
async function nextWorkNumber(repoRoot, dateStr) {
|
|
76
|
+
async function nextWorkNumber(repoRoot, dateStr, author) {
|
|
63
77
|
const dir = join(repoRoot, 'work');
|
|
64
78
|
const files = await listJsonFiles(dir);
|
|
65
|
-
|
|
66
|
-
const prefix = `w-${dateStr}-`;
|
|
67
|
-
for (const file of files) {
|
|
68
|
-
if (!file.startsWith(prefix)) continue;
|
|
69
|
-
const m = file.match(/^w-\d{4}-\d{2}-\d{2}-(\d{3})\.json$/);
|
|
70
|
-
if (!m) continue;
|
|
71
|
-
const n = parseInt(m[1], 10);
|
|
72
|
-
if (Number.isFinite(n) && n > max) max = n;
|
|
73
|
-
}
|
|
79
|
+
const max = maxNForAuthorOnDate(files, dateStr, author);
|
|
74
80
|
const next = max + 1;
|
|
75
81
|
if (next > 999) {
|
|
76
|
-
const err = new Error(
|
|
82
|
+
const err = new Error(
|
|
83
|
+
`no free work-item slot for ${dateStr} in author=${author} sequence (NNN exhausted)`,
|
|
84
|
+
);
|
|
77
85
|
err.exitCode = 2;
|
|
78
86
|
throw err;
|
|
79
87
|
}
|
|
80
|
-
return
|
|
88
|
+
return next;
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
/**
|
|
84
92
|
* Create a new work item. Returns { id, path }.
|
|
85
93
|
*
|
|
86
94
|
* Status defaults to "draft" per the brief. Date and NNN are
|
|
87
|
-
* auto-derived
|
|
95
|
+
* auto-derived; we do not accept overrides.
|
|
96
|
+
*
|
|
97
|
+
* Author token comes from `opts.author` (explicit; wins over anything
|
|
98
|
+
* else), the `PA_AUTHOR_TOKEN` env var, or the `paName` field of
|
|
99
|
+
* `operator/config/preferences.json` (passed in as
|
|
100
|
+
* `opts.preferencesPaName` by the CLI entry). If none resolve, throws
|
|
101
|
+
* with an actionable message naming all three sources — a mint with no
|
|
102
|
+
* author identity is the exact defect R1 exists to close, so we refuse
|
|
103
|
+
* to fall back to any hard-coded default.
|
|
88
104
|
*/
|
|
89
105
|
export async function createWork(opts) {
|
|
90
106
|
if (!opts.title) {
|
|
@@ -95,13 +111,24 @@ export async function createWork(opts) {
|
|
|
95
111
|
validateCategory(opts.category);
|
|
96
112
|
validatePriority(opts.priority);
|
|
97
113
|
|
|
114
|
+
let author;
|
|
115
|
+
try {
|
|
116
|
+
author = resolveAuthorToken({
|
|
117
|
+
explicit: opts.author,
|
|
118
|
+
preferencesPaName: opts.preferencesPaName,
|
|
119
|
+
});
|
|
120
|
+
} catch (e) {
|
|
121
|
+
e.exitCode = 1;
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
|
|
98
125
|
const repoRoot = await getRepoRoot();
|
|
99
126
|
const workDir = join(repoRoot, 'work');
|
|
100
127
|
await ensureDir(workDir);
|
|
101
128
|
|
|
102
129
|
const dateStr = today();
|
|
103
|
-
const nnn = await nextWorkNumber(repoRoot, dateStr);
|
|
104
|
-
const id =
|
|
130
|
+
const nnn = await nextWorkNumber(repoRoot, dateStr, author);
|
|
131
|
+
const id = buildWorkItemId({ date: dateStr, author, nnn });
|
|
105
132
|
const path = join(workDir, `${id}.json`);
|
|
106
133
|
|
|
107
134
|
const item = {
|
|
@@ -127,10 +154,16 @@ export async function createWork(opts) {
|
|
|
127
154
|
/**
|
|
128
155
|
* Set work-item status. Stamps `completedAt` to today's date when
|
|
129
156
|
* status=done (only if not already set). Always bumps `updated`.
|
|
157
|
+
*
|
|
158
|
+
* Accepts both legacy `w-YYYY-MM-DD-NNN` and writer-scoped
|
|
159
|
+
* `w-YYYY-MM-DD-<author>-NNN` ids — every existing item in the estate
|
|
160
|
+
* predates R1 and must remain mutable through this command.
|
|
130
161
|
*/
|
|
131
162
|
export async function setWorkStatus(id, status) {
|
|
132
|
-
if (!
|
|
133
|
-
const err = new Error(
|
|
163
|
+
if (!isValidWorkItemId(id)) {
|
|
164
|
+
const err = new Error(
|
|
165
|
+
`invalid work id: ${id} (expected w-YYYY-MM-DD-NNN or w-YYYY-MM-DD-<author>-NNN)`,
|
|
166
|
+
);
|
|
134
167
|
err.exitCode = 1;
|
|
135
168
|
throw err;
|
|
136
169
|
}
|
|
@@ -157,6 +190,12 @@ export async function setWorkStatus(id, status) {
|
|
|
157
190
|
* List work items. Filters: status, category, project, owner, assignee,
|
|
158
191
|
* mine, theirs, all, paName (used for --mine resolution).
|
|
159
192
|
* By default excludes done + archived (mirrors desk-list semantics).
|
|
193
|
+
*
|
|
194
|
+
* Reads any well-formed work item (both id forms). Skips files whose
|
|
195
|
+
* filename does not parse as a work-item id at all — the same
|
|
196
|
+
* skip-unparseable posture the original had, restated against the
|
|
197
|
+
* shared parser so an in-directory README or a stray temp file cannot
|
|
198
|
+
* poison the listing.
|
|
160
199
|
*/
|
|
161
200
|
export async function listWork(filters = {}) {
|
|
162
201
|
const repoRoot = await getRepoRoot();
|
|
@@ -164,10 +203,11 @@ export async function listWork(filters = {}) {
|
|
|
164
203
|
const files = await listJsonFiles(dir);
|
|
165
204
|
const items = [];
|
|
166
205
|
for (const f of files) {
|
|
206
|
+
if (!parseWorkItemId(f)) continue;
|
|
167
207
|
try {
|
|
168
208
|
items.push(await readJson(join(dir, f)));
|
|
169
209
|
} catch {
|
|
170
|
-
// Skip unparseable.
|
|
210
|
+
// Skip unparseable JSON contents.
|
|
171
211
|
}
|
|
172
212
|
}
|
|
173
213
|
let filtered = items;
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"properties": {
|
|
10
10
|
"id": {
|
|
11
11
|
"type": "string",
|
|
12
|
-
"pattern": "^w-\\d{4}-\\d{2}-\\d{2}
|
|
13
|
-
"description": "Work item identifier
|
|
12
|
+
"pattern": "^w-\\d{4}-\\d{2}-\\d{2}(?:-[a-z]{2,12})?-\\d{3}$",
|
|
13
|
+
"description": "Work item identifier. Two forms are permanently valid: legacy `w-YYYY-MM-DD-NNN` (pre-2026-08-12 items) and writer-scoped `w-YYYY-MM-DD-<author>-NNN` (post-R1 mints; author = /^[a-z]{2,12}$/ from operator/config/authors.json). New allocators must always emit the writer-scoped form — see packages/core/src/workItemId/index.mjs."
|
|
14
14
|
},
|
|
15
15
|
"projectId": {
|
|
16
16
|
"type": ["string", "null"],
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// workItemId/index.mjs — the canonical work-item-id primitive.
|
|
2
|
+
//
|
|
3
|
+
// One place, every rule. Every writer on the estate MUST parse work-item
|
|
4
|
+
// ids through `parseWorkItemId` (accepts both legal forms) and MUST mint
|
|
5
|
+
// new ids through `buildWorkItemId` (always the writer-scoped form).
|
|
6
|
+
// Nothing outside this module gets to know the shape of an id, and
|
|
7
|
+
// nothing outside this module gets to decide what the current writer's
|
|
8
|
+
// author token is.
|
|
9
|
+
//
|
|
10
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Background — w-2026-08-12-001, R1 of the library remediation train.
|
|
12
|
+
//
|
|
13
|
+
// The estate has ≥5 autonomous writers competing for one filename space
|
|
14
|
+
// (`work/w-YYYY-MM-DD-NNN.json`): Dave's macbook session, Dex's
|
|
15
|
+
// wsd-laptop session, librarian-pr-intake on ops-01, upstream-triage on
|
|
16
|
+
// smarthome01, Baz manual. Each allocated the next-free NNN from its own
|
|
17
|
+
// locally-visible git state, and sync latency meant visible state lied.
|
|
18
|
+
// Four dual-mint collisions in two weeks; the 2026-08-11 collision
|
|
19
|
+
// wedged the ops-01 librarian sweep for 2+ hours (see incident
|
|
20
|
+
// `2026-08-11-wedge-root-cause-d024.md` and library architecture review
|
|
21
|
+
// `2026-08-11-library-architecture-review.md` §2.4 collision surface).
|
|
22
|
+
//
|
|
23
|
+
// Baz ratified the fix 2026-08-11: writer-scoped per-day sequences. Each
|
|
24
|
+
// writer gets its own NNN space, keyed by an author token from the
|
|
25
|
+
// authors registry (`operator/config/authors.json`). Cross-writer
|
|
26
|
+
// collision is now structurally impossible with zero coordination.
|
|
27
|
+
// Legacy ids without an author segment remain valid PARSE targets
|
|
28
|
+
// forever (forward-only migration; existing items never renumbered) but
|
|
29
|
+
// no allocator may ever MINT them again.
|
|
30
|
+
//
|
|
31
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// The two legal forms
|
|
33
|
+
//
|
|
34
|
+
// legacy: w-YYYY-MM-DD-NNN e.g. w-2026-08-11-025
|
|
35
|
+
// new: w-YYYY-MM-DD-<author>-NNN e.g. w-2026-08-12-dave-001
|
|
36
|
+
//
|
|
37
|
+
// Author token = /^[a-z]{2,12}$/ (lowercase letters only, 2–12 chars).
|
|
38
|
+
// Deliberately all-alpha: keeps the parse alternation unambiguous
|
|
39
|
+
// against the NNN segment (any middle segment starting with a digit is
|
|
40
|
+
// legacy) and mirrors the registry's `dave`/`dex`/`sam`/`max`/`ian`/
|
|
41
|
+
// `baz` shape without inviting alphanumeric drift.
|
|
42
|
+
//
|
|
43
|
+
// NNN = /^\d{3}$/, 001–999, zero-padded. NNN space is per-author-per-day,
|
|
44
|
+
// which is why we never run out of headroom in practice.
|
|
45
|
+
|
|
46
|
+
const AUTHOR_TOKEN_RE = /^[a-z]{2,12}$/;
|
|
47
|
+
|
|
48
|
+
// Two-branch alternation — the ONE-line spec of every valid id in the
|
|
49
|
+
// estate. Every other regex in this repo is either this one, or (for
|
|
50
|
+
// filenames) this one with a `\.json` suffix.
|
|
51
|
+
const WORK_ITEM_ID_RE = /^w-(\d{4}-\d{2}-\d{2})(?:-([a-z]{2,12}))?-(\d{3})$/;
|
|
52
|
+
const WORK_ITEM_FILENAME_RE = /^w-(\d{4}-\d{2}-\d{2})(?:-([a-z]{2,12}))?-(\d{3})\.json$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True iff `s` is a well-formed author token.
|
|
56
|
+
* @param {unknown} s
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function isValidAuthorToken(s) {
|
|
60
|
+
return typeof s === 'string' && AUTHOR_TOKEN_RE.test(s);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse a work-item id (with or without `.json`). Accepts both the
|
|
65
|
+
* legacy `w-YYYY-MM-DD-NNN` form and the writer-scoped
|
|
66
|
+
* `w-YYYY-MM-DD-<author>-NNN` form. Returns `null` if the string is
|
|
67
|
+
* not a valid id in either form — callers decide how to handle that.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} idOrFilename
|
|
70
|
+
* @returns {{ date: string, author: string | null, nnn: number, id: string, isLegacy: boolean } | null}
|
|
71
|
+
*/
|
|
72
|
+
export function parseWorkItemId(idOrFilename) {
|
|
73
|
+
if (typeof idOrFilename !== 'string') return null;
|
|
74
|
+
const bare = idOrFilename.endsWith('.json')
|
|
75
|
+
? idOrFilename.slice(0, -'.json'.length)
|
|
76
|
+
: idOrFilename;
|
|
77
|
+
const m = bare.match(WORK_ITEM_ID_RE);
|
|
78
|
+
if (!m) return null;
|
|
79
|
+
const date = m[1];
|
|
80
|
+
const author = m[2] || null;
|
|
81
|
+
const nnn = parseInt(m[3], 10);
|
|
82
|
+
return { date, author, nnn, id: bare, isLegacy: author === null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* True iff `s` is a well-formed work-item id (either form). Convenience
|
|
87
|
+
* wrapper around `parseWorkItemId` for the many callers that only need
|
|
88
|
+
* a yes/no.
|
|
89
|
+
*
|
|
90
|
+
* @param {unknown} s
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function isValidWorkItemId(s) {
|
|
94
|
+
return parseWorkItemId(String(s)) !== null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Mint a writer-scoped id. NEW writes MUST always go through this
|
|
99
|
+
* function; the legacy form is parse-only and no allocator may re-emit
|
|
100
|
+
* it. Throws if `author` is not a valid token — that is a caller bug
|
|
101
|
+
* (an allocator with no resolved author identity), not a runtime input.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} opts
|
|
104
|
+
* @param {string} opts.date YYYY-MM-DD
|
|
105
|
+
* @param {string} opts.author registry-validated author token
|
|
106
|
+
* @param {number} opts.nnn 1..999
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function buildWorkItemId({ date, author, nnn }) {
|
|
110
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date))) {
|
|
111
|
+
throw new Error(`buildWorkItemId: bad date: ${date}`);
|
|
112
|
+
}
|
|
113
|
+
if (!isValidAuthorToken(author)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`buildWorkItemId: bad author token: ${JSON.stringify(author)} (expected /^[a-z]{2,12}$/)`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!Number.isInteger(nnn) || nnn < 1 || nnn > 999) {
|
|
119
|
+
throw new Error(`buildWorkItemId: nnn out of range: ${nnn} (expected 1..999)`);
|
|
120
|
+
}
|
|
121
|
+
return `w-${date}-${author}-${String(nnn).padStart(3, '0')}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the current caller's author token. Precedence, highest first:
|
|
126
|
+
*
|
|
127
|
+
* 1. `opts.explicit` — an explicit token passed by the caller
|
|
128
|
+
* (e.g. a HQ tool that KNOWS it is `max`
|
|
129
|
+
* or `ian`). ALWAYS wins.
|
|
130
|
+
* 2. `PA_AUTHOR_TOKEN` env — session/host default.
|
|
131
|
+
* 3. `opts.preferencesPaName` — `paName` from
|
|
132
|
+
* `operator/config/preferences.json`,
|
|
133
|
+
* the interactive-session fallback.
|
|
134
|
+
*
|
|
135
|
+
* Hard-errors with an actionable message naming every source if none
|
|
136
|
+
* resolves. Rationale: an allocator without a resolved author is a
|
|
137
|
+
* design bug — silently defaulting to a hard-coded token would put us
|
|
138
|
+
* back in the collision class this whole R1 exists to close.
|
|
139
|
+
*
|
|
140
|
+
* The precedence ordering is load-bearing: HQ tools deployed on ops-01
|
|
141
|
+
* (which shares $HOME across many launch contexts) MUST be able to pin
|
|
142
|
+
* their own token via the explicit param without an ambient
|
|
143
|
+
* `PA_AUTHOR_TOKEN` inherited from the parent shell silently
|
|
144
|
+
* overriding them. Tested by
|
|
145
|
+
* `test/workItemId/resolveAuthorToken.test.mjs`.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} [opts]
|
|
148
|
+
* @param {string} [opts.explicit]
|
|
149
|
+
* @param {string} [opts.envToken] — inject env for tests; defaults to process.env.PA_AUTHOR_TOKEN
|
|
150
|
+
* @param {string} [opts.preferencesPaName] — `paName` field from preferences.json, if loaded
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
export function resolveAuthorToken(opts = {}) {
|
|
154
|
+
const envToken = 'envToken' in opts ? opts.envToken : process.env.PA_AUTHOR_TOKEN;
|
|
155
|
+
const candidates = [
|
|
156
|
+
{ source: 'explicit param', value: opts.explicit },
|
|
157
|
+
{ source: 'PA_AUTHOR_TOKEN env var', value: envToken },
|
|
158
|
+
{ source: 'preferences.json paName', value: opts.preferencesPaName },
|
|
159
|
+
];
|
|
160
|
+
for (const { source, value } of candidates) {
|
|
161
|
+
if (value == null || value === '') continue;
|
|
162
|
+
if (!isValidAuthorToken(value)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`resolveAuthorToken: ${source} is not a valid author token: ${JSON.stringify(value)} (expected /^[a-z]{2,12}$/)`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
throw new Error(
|
|
170
|
+
'resolveAuthorToken: no author token resolved. Set one of, in order of precedence: ' +
|
|
171
|
+
'(1) an explicit token via the caller\'s API, ' +
|
|
172
|
+
'(2) the PA_AUTHOR_TOKEN environment variable, ' +
|
|
173
|
+
'(3) `paName` in operator/config/preferences.json. ' +
|
|
174
|
+
'Token must match /^[a-z]{2,12}$/. Registry lives at operator/config/authors.json.',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Compute the highest NNN already allocated for a given `date` +
|
|
180
|
+
* `author` slot from a bag of parsed work-item filenames. Legacy ids
|
|
181
|
+
* (no author segment) DO NOT count toward any author's sequence — each
|
|
182
|
+
* writer's per-day NNN space starts from 001 on the day writer-scoping
|
|
183
|
+
* takes effect, and legacy items just occupy a separate, older space.
|
|
184
|
+
*
|
|
185
|
+
* Purpose: the arithmetic every allocator does is identical; keep the
|
|
186
|
+
* rule in one place so a new writer added to the registry doesn't need
|
|
187
|
+
* to re-implement it.
|
|
188
|
+
*
|
|
189
|
+
* @param {string[]} filenames raw directory entries
|
|
190
|
+
* @param {string} date YYYY-MM-DD
|
|
191
|
+
* @param {string} author the author whose sequence is being read
|
|
192
|
+
* @returns {number} 0 if the author has no items yet today
|
|
193
|
+
*/
|
|
194
|
+
export function maxNForAuthorOnDate(filenames, date, author) {
|
|
195
|
+
if (!isValidAuthorToken(author)) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`maxNForAuthorOnDate: bad author: ${JSON.stringify(author)} (expected /^[a-z]{2,12}$/)`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
let max = 0;
|
|
201
|
+
for (const name of filenames) {
|
|
202
|
+
const parsed = parseWorkItemId(name);
|
|
203
|
+
if (!parsed) continue;
|
|
204
|
+
if (parsed.date !== date) continue;
|
|
205
|
+
if (parsed.author !== author) continue;
|
|
206
|
+
if (parsed.nnn > max) max = parsed.nnn;
|
|
207
|
+
}
|
|
208
|
+
return max;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The regex sources, exported for callers that MUST use a raw string
|
|
213
|
+
* (JSON Schema `pattern`, other language regexes). Do not reach into
|
|
214
|
+
* `.source` in JavaScript — call `parseWorkItemId`/`isValidWorkItemId`
|
|
215
|
+
* instead.
|
|
216
|
+
*/
|
|
217
|
+
export const WORK_ITEM_ID_PATTERN = WORK_ITEM_ID_RE.source;
|
|
218
|
+
export const WORK_ITEM_FILENAME_PATTERN = WORK_ITEM_FILENAME_RE.source;
|
|
219
|
+
export const AUTHOR_TOKEN_PATTERN = AUTHOR_TOKEN_RE.source;
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stravica-ai/digital-operator-pa",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Digital Operator PA
|
|
3
|
+
"version": "0.1.21",
|
|
4
|
+
"description": "Digital Operator PA \u2014 single-bundled public package surface (bins + library) for the platform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
7
7
|
"exports": {
|
|
@@ -49,10 +49,11 @@
|
|
|
49
49
|
"engines": {
|
|
50
50
|
"node": ">=24"
|
|
51
51
|
},
|
|
52
|
-
"//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper — see packages/cli/test/secrets. Counts must never lie.",
|
|
53
52
|
"scripts": {
|
|
54
53
|
"build": "node build.mjs",
|
|
54
|
+
"prepack": "node build.mjs",
|
|
55
55
|
"test": "node --test --test-isolation=none test/*.test.mjs",
|
|
56
56
|
"dry-publish": "node build.mjs && node scripts/dry-publish.mjs"
|
|
57
|
-
}
|
|
58
|
-
|
|
57
|
+
},
|
|
58
|
+
"//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper \u2014 see packages/cli/test/secrets. Counts must never lie."
|
|
59
|
+
}
|