@stravica-ai/digital-operator-pa 0.1.19 → 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/docs/secrets.md +66 -23
- 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-cli/src/secrets/cli.mjs +36 -15
- package/dist/engineering-pa-cli/src/secrets/commands/set.mjs +74 -18
- package/dist/engineering-pa-cli/src/secrets/vault.mjs +140 -0
- 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
|
|
@@ -8,7 +8,7 @@ Dave uses SOPS with age encryption for secrets management. Secrets are encrypted
|
|
|
8
8
|
|
|
9
9
|
Implemented: `init`, `get`, `set`, `list`, `delete`, `rotate`, `status`, `backup`, `vaults`, `find` (alias `which`), `verify`. No npm dependencies — shells out to `sops` and `age` via `node:child_process.spawn`.
|
|
10
10
|
|
|
11
|
-
`set`
|
|
11
|
+
**Canonical-first dual-write is the DEFAULT for scoped `set` since 0.1.20.** A plain `secrets set` on a scoped vault writes canonical FIRST and workspace SECOND whenever a canonical clone for the scope is discoverable on this host (via `~/.config/stravica-pa/defaults.json`'s `canonicalClone` key or the `~/stravica-canonical/*/vault/<scope>.enc.json` estate convention). Absence of a canonical clone is a stderr NOTICE, never a failure. Explicit `--canonical-clone PATH` still wins over auto-discovery (and bootstraps a missing file); `--no-canonical` opts out entirely and suppresses the notice. `verify --values --against PATH` does a byte-level comparison of DECRYPTED values between the resolved vault and a reference vault at PATH — never a decrypt-only pass. See "Value-compare verification" and "Canonical-first writes" below.
|
|
12
12
|
|
|
13
13
|
`get`/`set` run without `--scope`/`--file` now resolve a **default scope** — `$SECRETS_DEFAULT_SCOPE` env var, then `"defaultScope"` in `~/.config/stravica-pa/defaults.json`, then the calling PA's own `agents/<name>/identity.json`, then the legacy single-file vault as a last resort. Every such invocation prints a one-line stderr notice naming the target vault BEFORE the operation runs, including the legacy-fallback case ("no scope specified and no default configured") — see `secrets --help` → "Default scope" for the full precedence and example notices.
|
|
14
14
|
|
|
@@ -67,11 +67,17 @@ node core/tools/secrets/bin/secrets status
|
|
|
67
67
|
# Open a browser with a QR code of the master key for backup
|
|
68
68
|
node core/tools/secrets/bin/secrets backup
|
|
69
69
|
|
|
70
|
-
#
|
|
71
|
-
# Aborts (exit 2) before touching the workspace copy if
|
|
72
|
-
# write fails (e.g. the existing canonical store can't be
|
|
70
|
+
# Force a specific canonical file (wins over auto-discovery; bootstraps
|
|
71
|
+
# a missing file). Aborts (exit 2) before touching the workspace copy if
|
|
72
|
+
# the canonical write fails (e.g. the existing canonical store can't be
|
|
73
|
+
# decrypted). Auto-discovery is on by default since 0.1.20 — this flag
|
|
74
|
+
# is only for pointing at a specific target.
|
|
73
75
|
node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --canonical-clone /path/to/canonical/vault/scope.enc.json
|
|
74
76
|
|
|
77
|
+
# Opt out of canonical dual-write entirely (suppresses the absence
|
|
78
|
+
# notice too). Mutually exclusive with --canonical-clone.
|
|
79
|
+
node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --no-canonical
|
|
80
|
+
|
|
75
81
|
# Byte-level comparison of DECRYPTED values against a reference vault.
|
|
76
82
|
# --values is required — there is no decrypt-only verification mode
|
|
77
83
|
# (decrypt-only false-passes a vault that decrypts cleanly but holds the
|
|
@@ -79,29 +85,66 @@ node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --canonical-clone /p
|
|
|
79
85
|
node core/tools/secrets/bin/secrets verify --values --against /path/to/other/vault/scope.enc.json
|
|
80
86
|
```
|
|
81
87
|
|
|
82
|
-
### Canonical-first writes
|
|
88
|
+
### Canonical-first writes
|
|
89
|
+
|
|
90
|
+
Canonical-first dual-write is the DEFAULT for scoped `set` since 0.1.20 —
|
|
91
|
+
a plain `secrets set` on a scoped vault writes canonical FIRST and the
|
|
92
|
+
workspace SECOND whenever a canonical clone for the scope is discoverable
|
|
93
|
+
on this host. The abort-before-workspace ordering is identical to the
|
|
94
|
+
explicit-flag path it grew out of: if the canonical write fails for any
|
|
95
|
+
reason (existing canonical store undecryptable, sops encrypt failure),
|
|
96
|
+
the command exits 2 and the workspace copy is never touched.
|
|
97
|
+
|
|
98
|
+
**Resolution order** (first hit wins; every miss recorded in the absence
|
|
99
|
+
notice's tried-tier detail):
|
|
100
|
+
|
|
101
|
+
1. **Explicit `--canonical-clone PATH`** — always wins, bootstraps a
|
|
102
|
+
missing file (this is the flag's historical behaviour).
|
|
103
|
+
2. **`"canonicalClone"` in `~/.config/stravica-pa/defaults.json`** — the
|
|
104
|
+
same file (already read for `workspaceRoot` and `defaultScope`); the
|
|
105
|
+
CLI never writes it. Two shapes supported:
|
|
106
|
+
- **Object (scope → file map):**
|
|
107
|
+
```json
|
|
108
|
+
{ "canonicalClone": { "seo-ops": "/Users/thefoot/stravica-canonical/stravica-operator-barry/vault/seo-ops.enc.json" } }
|
|
109
|
+
```
|
|
110
|
+
- **String (root dir)** — derives `<root>/vault/<scope>.enc.json`,
|
|
111
|
+
which fits the estate's operator-repo layout:
|
|
112
|
+
```json
|
|
113
|
+
{ "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
|
|
114
|
+
```
|
|
115
|
+
3. **Estate convention** — `~/stravica-canonical/<repo>/vault/<scope>.enc.json`
|
|
116
|
+
(glob shape `~/stravica-canonical/*/vault/…enc.json`). This reuses the
|
|
117
|
+
root the rest of the estate already uses (see
|
|
118
|
+
`library-sync-cycle`'s `--repos-root` default and
|
|
119
|
+
`dex-cutover-driver`'s `reposRoot`). Exactly one match wins; ambiguity
|
|
120
|
+
(multiple matches) or zero matches skips this tier.
|
|
121
|
+
|
|
122
|
+
**Absence** is a stderr notice, never a failure — a host with no
|
|
123
|
+
canonical layout keeps writing to workspace only:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
notice: no canonical clone configured for scope 'seo-ops' on this host — workspace-only write (tried: …)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The `tried:` tail names every tier that missed and why, so an operator on
|
|
130
|
+
a host that ought to have a canonical knows exactly where to configure it
|
|
131
|
+
(or that `--no-canonical` is the intended answer for a host that never
|
|
132
|
+
had one).
|
|
83
133
|
|
|
84
|
-
|
|
85
|
-
resolved workspace copy. With `--canonical-clone PATH`, `set`:
|
|
134
|
+
**Flags:**
|
|
86
135
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
2. Decrypts the existing canonical store (if any) and writes the new key
|
|
93
|
-
into it. **If this fails for any reason — the existing canonical
|
|
94
|
-
store can't be decrypted, or the encrypt itself fails — `set` aborts
|
|
95
|
-
with exit 2 and the workspace copy is never touched.**
|
|
96
|
-
3. Only on canonical success does it proceed to the existing
|
|
97
|
-
decrypt-mutate-encrypt cycle against the resolved workspace copy.
|
|
136
|
+
- `--canonical-clone PATH` — force a specific canonical file (wins over
|
|
137
|
+
auto-discovery; bootstraps a missing file). Backwards-compatible with
|
|
138
|
+
every prior release.
|
|
139
|
+
- `--no-canonical` — opt out of dual-write entirely, and suppress the
|
|
140
|
+
absence notice. Mutually exclusive with `--canonical-clone`.
|
|
98
141
|
|
|
99
142
|
This closes the "device-side vault mutation only round-trips halfway"
|
|
100
|
-
class
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
143
|
+
class BY DEFAULT rather than opt-in: any workspace write that a
|
|
144
|
+
canonical layout could catch, it now does — no flag required.
|
|
145
|
+
|
|
146
|
+
Legacy vaults (no scope) never trigger canonical resolution — the notice
|
|
147
|
+
would be pure noise for a fallback that has no natural canonical mirror.
|
|
105
148
|
|
|
106
149
|
### Value-compare verification (`verify --values --against`)
|
|
107
150
|
|
|
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;
|
|
@@ -25,10 +25,17 @@ Usage:
|
|
|
25
25
|
secrets init Initialise the vault (generate keys, create empty secrets file)
|
|
26
26
|
secrets get KEY Retrieve a secret
|
|
27
27
|
secrets set KEY VALUE Store or update a secret (use \`-\` as VALUE to read from stdin)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
Since 0.1.20, canonical-first dual-write is
|
|
29
|
+
the DEFAULT for scoped writes when a canonical
|
|
30
|
+
clone for the scope is discoverable on this
|
|
31
|
+
host. See "Canonical-first writes" below.
|
|
32
|
+
--canonical-clone PATH: force a specific
|
|
33
|
+
canonical clone's vault file (bootstraps a
|
|
34
|
+
missing file; wins over auto-discovery).
|
|
35
|
+
--no-canonical: opt out of the dual-write and
|
|
36
|
+
the absence notice.
|
|
37
|
+
If the canonical write fails, aborts before
|
|
38
|
+
touching the workspace copy at all.
|
|
32
39
|
secrets list List all secret key names (not values)
|
|
33
40
|
secrets delete KEY Remove a secret
|
|
34
41
|
secrets rotate Re-encrypt with current recipients
|
|
@@ -111,16 +118,30 @@ Default scope (get/set only, when neither --scope nor --file is given):
|
|
|
111
118
|
note: writing to '(legacy)' pot — no scope specified and no default configured. Use --scope <name> or configure a default.
|
|
112
119
|
|
|
113
120
|
Canonical-first writes (set only):
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
Canonical-first dual-write is DEFAULT for scoped writes since 0.1.20 —
|
|
122
|
+
a plain 'secrets set' on a scoped vault writes canonical FIRST and
|
|
123
|
+
workspace SECOND whenever a canonical clone for the scope is
|
|
124
|
+
discoverable on this host. Discovery order, first hit wins:
|
|
125
|
+
1. Explicit --canonical-clone PATH (always wins, bootstraps a
|
|
126
|
+
missing file).
|
|
127
|
+
2. "canonicalClone" in ~/.config/stravica-pa/defaults.json — either:
|
|
128
|
+
object (scope → file map):
|
|
129
|
+
{ "canonicalClone": { "seo-ops": "/abs/vault/seo-ops.enc.json" } }
|
|
130
|
+
string (root dir), derives <root>/vault/<scope>.enc.json:
|
|
131
|
+
{ "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
|
|
132
|
+
3. Estate convention: ~/stravica-canonical/*/vault/<scope>.enc.json
|
|
133
|
+
(exactly one match; ambiguity or zero matches skips this tier).
|
|
134
|
+
Absence is a NOTICE, never a failure — a host with no canonical layout
|
|
135
|
+
keeps writing to workspace only, with a one-line stderr line naming why
|
|
136
|
+
nothing resolved. If any canonical write fails (existing store
|
|
137
|
+
undecryptable, sops encrypt failure), the command aborts with exit 2
|
|
138
|
+
BEFORE touching the workspace copy — the ordering invariant is
|
|
139
|
+
identical to the flag path since it always was.
|
|
140
|
+
|
|
141
|
+
--canonical-clone PATH Force PATH as the canonical target (wins over
|
|
142
|
+
auto-discovery; bootstraps a missing file).
|
|
143
|
+
--no-canonical Opt out of dual-write entirely, and suppress
|
|
144
|
+
the absence notice.
|
|
124
145
|
|
|
125
146
|
Value-compare verification:
|
|
126
147
|
secrets verify --values --against <path>
|
|
@@ -139,7 +160,7 @@ const USAGE = `Usage: secrets {init|get|set|list|delete|rotate|status|backup|vau
|
|
|
139
160
|
Commands:
|
|
140
161
|
init Initialise the vault (generate keys, create config)
|
|
141
162
|
get KEY Retrieve a secret value
|
|
142
|
-
set KEY VALUE Store or update a secret (
|
|
163
|
+
set KEY VALUE Store or update a secret (canonical-first by default; --no-canonical to opt out)
|
|
143
164
|
list List all secret key names
|
|
144
165
|
delete KEY Remove a secret
|
|
145
166
|
rotate Re-encrypt with current recipients
|
|
@@ -2,18 +2,29 @@
|
|
|
2
2
|
// Passing `-` as VALUE reads the secret from stdin (trailing newline stripped),
|
|
3
3
|
// which keeps the value out of argv / process listings / shell history.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// flag
|
|
5
|
+
// Canonical-first dual-write is the DEFAULT for scoped writes since 0.1.20:
|
|
6
|
+
// when a canonical clone for the target scope is discoverable on this host
|
|
7
|
+
// (via ~/.config/stravica-pa/defaults.json "canonicalClone" or the
|
|
8
|
+
// ~/stravica-canonical/*/vault/<scope>.enc.json convention — see
|
|
9
|
+
// resolveCanonicalCloneTarget in ../vault.mjs), set writes canonical FIRST
|
|
10
|
+
// and workspace SECOND, with the same abort-before-workspace ordering the
|
|
11
|
+
// explicit --canonical-clone flag has always had. Absence of a canonical
|
|
12
|
+
// clone is a NOTICE, never a failure (a host that never had a canonical
|
|
13
|
+
// keeps working); --no-canonical opts out (and suppresses the notice).
|
|
14
|
+
// Explicit --canonical-clone PATH still wins over any auto-discovery.
|
|
15
|
+
//
|
|
16
|
+
// Ordering invariant (pinned by tests): if the canonical write fails for
|
|
17
|
+
// any reason (existing canonical store undecryptable, sops encrypt
|
|
18
|
+
// failure), the command aborts with exit 2 BEFORE touching the workspace
|
|
19
|
+
// copy at all — a device-side vault mutation never round-trips only
|
|
20
|
+
// halfway (see up-path-single-writer-ownership's vault follow-up:
|
|
21
|
+
// "device-side vault mutations must round-trip to canonical immediately").
|
|
12
22
|
|
|
13
23
|
import { readFileSync } from 'node:fs';
|
|
14
24
|
import {
|
|
15
25
|
resolveCommandPaths,
|
|
16
26
|
resolveScopePaths,
|
|
27
|
+
resolveCanonicalCloneTarget,
|
|
17
28
|
checkDeps,
|
|
18
29
|
depInstallMessage,
|
|
19
30
|
checkVault,
|
|
@@ -25,19 +36,21 @@ import {
|
|
|
25
36
|
VaultError,
|
|
26
37
|
} from '../vault.mjs';
|
|
27
38
|
|
|
28
|
-
// --canonical-clone may appear anywhere in argv, matching
|
|
29
|
-
// --file convention in cli.mjs. Local to `set` (not
|
|
30
|
-
// no other command writes to a second target.
|
|
31
|
-
function
|
|
39
|
+
// --canonical-clone / --no-canonical may appear anywhere in argv, matching
|
|
40
|
+
// the --scope/--file convention in cli.mjs. Local to `set` (not global
|
|
41
|
+
// flags) since no other command writes to a second target.
|
|
42
|
+
function extractCanonicalFlags(argv) {
|
|
32
43
|
const rest = [];
|
|
33
44
|
let canonicalClone;
|
|
45
|
+
let noCanonical = false;
|
|
34
46
|
for (let i = 0; i < argv.length; i += 1) {
|
|
35
47
|
const a = argv[i];
|
|
36
48
|
if (a === '--canonical-clone') { canonicalClone = argv[i + 1]; i += 1; continue; }
|
|
37
49
|
if (a.startsWith('--canonical-clone=')) { canonicalClone = a.slice('--canonical-clone='.length); continue; }
|
|
50
|
+
if (a === '--no-canonical') { noCanonical = true; continue; }
|
|
38
51
|
rest.push(a);
|
|
39
52
|
}
|
|
40
|
-
return { canonicalClone, rest };
|
|
53
|
+
return { canonicalClone, noCanonical, rest };
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
export async function runSet(args, overrides = {}) {
|
|
@@ -45,15 +58,20 @@ export async function runSet(args, overrides = {}) {
|
|
|
45
58
|
// target vault) whenever --scope/--file wasn't given upstream in
|
|
46
59
|
// cli.mjs — see vault.mjs's default-scope resolution section.
|
|
47
60
|
const paths = resolveCommandPaths(overrides);
|
|
48
|
-
const { canonicalClone, rest } =
|
|
61
|
+
const { canonicalClone, noCanonical, rest } = extractCanonicalFlags(args);
|
|
49
62
|
const key = rest[0];
|
|
50
63
|
let value = rest[1];
|
|
51
64
|
|
|
52
65
|
if (!key || value == null || value === '') {
|
|
53
|
-
console.error('Usage: secrets set KEY VALUE [--canonical-clone PATH] (use `-` as VALUE to read from stdin)');
|
|
66
|
+
console.error('Usage: secrets set KEY VALUE [--canonical-clone PATH | --no-canonical] (use `-` as VALUE to read from stdin)');
|
|
54
67
|
throw new VaultError('usage', 1);
|
|
55
68
|
}
|
|
56
69
|
|
|
70
|
+
if (canonicalClone && noCanonical) {
|
|
71
|
+
console.error('Error: --canonical-clone and --no-canonical are mutually exclusive.');
|
|
72
|
+
throw new VaultError('mutually exclusive canonical flags', 1);
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
if (value === '-') {
|
|
58
76
|
const readStdin = overrides.readStdin ?? (() => readFileSync(0, 'utf-8'));
|
|
59
77
|
const raw = readStdin();
|
|
@@ -79,16 +97,54 @@ export async function runSet(args, overrides = {}) {
|
|
|
79
97
|
throw new VaultError(vaultState.reason || 'vault not initialised', vaultState.exitCode ?? 1);
|
|
80
98
|
}
|
|
81
99
|
|
|
100
|
+
// Canonical-first resolution order (BEFORE any workspace write):
|
|
101
|
+
// 1. Explicit --canonical-clone PATH → wins, always. Bootstraps a
|
|
102
|
+
// missing canonical file (matches the flag's historical behaviour).
|
|
103
|
+
// 2. --no-canonical → skip canonical entirely, no notice, no discovery.
|
|
104
|
+
// 3. Scoped write (paths.scope set) → auto-discover via
|
|
105
|
+
// resolveCanonicalCloneTarget. Hit → dual-write canonical-first,
|
|
106
|
+
// SAME output lines as the explicit-flag path. Miss → workspace-only
|
|
107
|
+
// PLUS a one-line stderr notice naming why nothing resolved.
|
|
108
|
+
// 4. Legacy fallback (no scope) → no canonical resolution, no notice —
|
|
109
|
+
// legacy has no natural canonical mirror.
|
|
110
|
+
let effectiveCanonical = null;
|
|
111
|
+
let canonicalPathsFromOverride = overrides.canonicalPaths || null;
|
|
112
|
+
if (canonicalClone) {
|
|
113
|
+
effectiveCanonical = { file: canonicalClone, source: 'flag' };
|
|
114
|
+
} else if (!noCanonical && paths.scope) {
|
|
115
|
+
// Honour the paths' own `home` first — it carries the caller's
|
|
116
|
+
// injected home end-to-end so test isolation cannot leak into the
|
|
117
|
+
// real ~/stravica-canonical/ scan. Falls back to overrides.home for
|
|
118
|
+
// callers that build paths themselves and only pass home via
|
|
119
|
+
// overrides, then to homedir() for the real CLI at run time.
|
|
120
|
+
const canonicalHome = paths.home ?? overrides.home;
|
|
121
|
+
const resolved = overrides.resolveCanonicalCloneTarget
|
|
122
|
+
? overrides.resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env })
|
|
123
|
+
: resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env });
|
|
124
|
+
if (resolved.file) {
|
|
125
|
+
effectiveCanonical = { file: resolved.file, source: resolved.source, detail: resolved.detail };
|
|
126
|
+
} else {
|
|
127
|
+
// Absence notice — never a failure. Explains WHY nothing resolved so
|
|
128
|
+
// an operator on a host without a canonical layout knows whether to
|
|
129
|
+
// provision one, pass --no-canonical to silence, or ignore.
|
|
130
|
+
const tail = resolved.tried && resolved.tried.length > 0
|
|
131
|
+
? ` (tried: ${resolved.tried.join('; ')})`
|
|
132
|
+
: '';
|
|
133
|
+
console.error(`notice: no canonical clone configured for scope '${paths.scope}' on this host — workspace-only write${tail}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
82
137
|
// Canonical FIRST. Resolved the same way `--file` already works for
|
|
83
138
|
// every other scope-aware command — an arbitrary path, its own
|
|
84
139
|
// .sops.yaml expected alongside it, reusing THIS host's key material
|
|
85
140
|
// (same recipients in practice for a workspace/canonical-clone pair).
|
|
86
141
|
// Any failure here (existing canonical store undecryptable, sops
|
|
87
142
|
// encrypt failure) throws and propagates straight out of runSet —
|
|
88
|
-
// the workspace write below is never reached.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
143
|
+
// the workspace write below is never reached. Ordering pinned by
|
|
144
|
+
// canonical-clone.test.mjs — do not reorder.
|
|
145
|
+
if (effectiveCanonical) {
|
|
146
|
+
const canonicalPaths = canonicalPathsFromOverride || resolveScopePaths({
|
|
147
|
+
file: effectiveCanonical.file,
|
|
92
148
|
keyDir: paths.keyDir,
|
|
93
149
|
keyFile: paths.keyFile,
|
|
94
150
|
});
|
|
@@ -165,6 +165,11 @@ export function defaultPaths(overrides = {}) {
|
|
|
165
165
|
sopsConfig: join(vaultDir, '.sops.yaml'),
|
|
166
166
|
keyDir,
|
|
167
167
|
keyFile: join(keyDir, 'age-key.txt'),
|
|
168
|
+
// Retained on the paths object so downstream consumers (canonical-
|
|
169
|
+
// clone auto-discovery in commands/set.mjs) can honour the SAME home
|
|
170
|
+
// the caller injected, instead of falling back to the real user's
|
|
171
|
+
// homedir() and cross-contaminating test isolation.
|
|
172
|
+
home,
|
|
168
173
|
};
|
|
169
174
|
}
|
|
170
175
|
|
|
@@ -220,6 +225,7 @@ export function resolveScopePaths(opts = {}) {
|
|
|
220
225
|
scope: opts.scope ?? null,
|
|
221
226
|
scoped: true,
|
|
222
227
|
layout: 'file',
|
|
228
|
+
home,
|
|
223
229
|
};
|
|
224
230
|
}
|
|
225
231
|
|
|
@@ -260,6 +266,7 @@ export function resolveScopePaths(opts = {}) {
|
|
|
260
266
|
scope: opts.scope,
|
|
261
267
|
scoped: true,
|
|
262
268
|
layout: 'workspace',
|
|
269
|
+
home,
|
|
263
270
|
};
|
|
264
271
|
}
|
|
265
272
|
|
|
@@ -275,6 +282,7 @@ export function resolveScopePaths(opts = {}) {
|
|
|
275
282
|
scope: opts.scope,
|
|
276
283
|
scoped: true,
|
|
277
284
|
layout: 'canonical',
|
|
285
|
+
home,
|
|
278
286
|
};
|
|
279
287
|
}
|
|
280
288
|
|
|
@@ -332,6 +340,138 @@ function readDefaultsDefaultScope(home) {
|
|
|
332
340
|
}
|
|
333
341
|
}
|
|
334
342
|
|
|
343
|
+
// ── Canonical-clone target resolution (set only) ─────────────────────
|
|
344
|
+
//
|
|
345
|
+
// When `set` runs on a scoped vault WITHOUT --canonical-clone (and WITHOUT
|
|
346
|
+
// --no-canonical), auto-discover the canonical clone's vault file for the
|
|
347
|
+
// resolved scope. Resolution order (each tier tried in turn, first hit
|
|
348
|
+
// wins; every miss recorded in `tried` for the absence-notice detail):
|
|
349
|
+
//
|
|
350
|
+
// 1. "canonicalClone" in ~/.config/stravica-pa/defaults.json — two
|
|
351
|
+
// shapes supported:
|
|
352
|
+
// - object (scope → file map):
|
|
353
|
+
// { "canonicalClone": { "seo-ops": "/abs/path/seo-ops.enc.json" } }
|
|
354
|
+
// - string (root dir): derives <root>/vault/<scope>.enc.json:
|
|
355
|
+
// { "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
|
|
356
|
+
// This CLI never writes defaults.json.
|
|
357
|
+
// 2. Estate convention discovery under ~/stravica-canonical/*/vault/
|
|
358
|
+
// <scope>.enc.json — the ~/stravica-canonical/ layout is the
|
|
359
|
+
// established root for canonical clones on every host (see
|
|
360
|
+
// library-sync-cycle's default --repos-root, dex-cutover-driver's
|
|
361
|
+
// reposRoot, and the routine references across projects/). Exactly
|
|
362
|
+
// one match wins; zero matches or multiple matches skips this tier
|
|
363
|
+
// (multiple is recorded as ambiguity so the notice explains WHY the
|
|
364
|
+
// auto-write did not happen).
|
|
365
|
+
//
|
|
366
|
+
// A resolved-but-nonexistent file (config points at a path that isn't
|
|
367
|
+
// there) is treated as a miss with a `tried` line, not a bootstrap — the
|
|
368
|
+
// auto-write path only ever writes to canonicals that already exist.
|
|
369
|
+
// Explicit --canonical-clone still bootstraps missing files (it always did).
|
|
370
|
+
|
|
371
|
+
function readDefaultsCanonicalClone(home) {
|
|
372
|
+
const file = defaultsFilePath(home);
|
|
373
|
+
if (!existsSync(file)) return { file, shape: null, value: null, reason: 'file not present' };
|
|
374
|
+
let parsed;
|
|
375
|
+
try {
|
|
376
|
+
parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
377
|
+
} catch {
|
|
378
|
+
return { file, shape: null, value: null, reason: 'file is not valid JSON' };
|
|
379
|
+
}
|
|
380
|
+
if (!parsed || typeof parsed !== 'object' || !('canonicalClone' in parsed)) {
|
|
381
|
+
return { file, shape: null, value: null, reason: 'canonicalClone key not set' };
|
|
382
|
+
}
|
|
383
|
+
const v = parsed.canonicalClone;
|
|
384
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
385
|
+
return { file, shape: 'root', value: resolve(v.trim()), reason: null };
|
|
386
|
+
}
|
|
387
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
388
|
+
return { file, shape: 'map', value: v, reason: null };
|
|
389
|
+
}
|
|
390
|
+
return { file, shape: null, value: null, reason: 'canonicalClone must be a non-empty string or a scope→file object' };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Directory scan for the estate convention: ~/stravica-canonical/<repo>/
|
|
394
|
+
// vault/<scope>.enc.json (glob-shape ~/stravica-canonical/*/vault/…enc.json;
|
|
395
|
+
// slash-star notation avoided in this comment because JSDoc closes on */).
|
|
396
|
+
// Returns { file, source, detail } on exactly-one hit, or
|
|
397
|
+
// { file: null, reason } describing the miss (none / ambiguous / root
|
|
398
|
+
// absent / unreadable). Never throws — a scan failure is a miss, not a
|
|
399
|
+
// secrets-set failure.
|
|
400
|
+
function discoverConventionalCanonical(home, scope) {
|
|
401
|
+
const root = join(home, 'stravica-canonical');
|
|
402
|
+
if (!existsSync(root)) {
|
|
403
|
+
return { file: null, reason: `no ${root} directory` };
|
|
404
|
+
}
|
|
405
|
+
let entries;
|
|
406
|
+
try {
|
|
407
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
408
|
+
} catch (err) {
|
|
409
|
+
return { file: null, reason: `${root} unreadable (${err && err.code ? err.code : 'error'})` };
|
|
410
|
+
}
|
|
411
|
+
const hits = [];
|
|
412
|
+
for (const ent of entries) {
|
|
413
|
+
if (!ent.isDirectory()) continue;
|
|
414
|
+
const candidate = join(root, ent.name, 'vault', `${scope}.enc.json`);
|
|
415
|
+
if (existsSync(candidate)) hits.push(candidate);
|
|
416
|
+
}
|
|
417
|
+
if (hits.length === 1) {
|
|
418
|
+
return { file: hits[0], source: 'convention', detail: `~/stravica-canonical/*/vault/${scope}.enc.json` };
|
|
419
|
+
}
|
|
420
|
+
if (hits.length === 0) {
|
|
421
|
+
return { file: null, reason: `no ~/stravica-canonical/*/vault/${scope}.enc.json` };
|
|
422
|
+
}
|
|
423
|
+
return { file: null, reason: `ambiguous — ${hits.length} matches under ~/stravica-canonical: ${hits.join(', ')}` };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Resolve the canonical clone's vault file for `scope`. Returns
|
|
428
|
+
* { file, source, detail, tried } on hit — file is an absolute path,
|
|
429
|
+
* source is 'defaults-map' |
|
|
430
|
+
* 'defaults-root' | 'convention'.
|
|
431
|
+
* { file: null, tried } on miss — tried is a list of the
|
|
432
|
+
* per-tier miss reasons, in
|
|
433
|
+
* priority order.
|
|
434
|
+
* Never throws. Never a failure — absence is a notice case, not an error.
|
|
435
|
+
* Scope is required; callers must not invoke this on the legacy vault
|
|
436
|
+
* fall-through (no scope → no natural canonical mirror).
|
|
437
|
+
*/
|
|
438
|
+
export function resolveCanonicalCloneTarget(opts = {}) {
|
|
439
|
+
const home = opts.home ?? homedir();
|
|
440
|
+
const scope = opts.scope;
|
|
441
|
+
const tried = [];
|
|
442
|
+
if (!scope) return { file: null, tried: ['no scope resolved (legacy fallback)'] };
|
|
443
|
+
|
|
444
|
+
const defaults = readDefaultsCanonicalClone(home);
|
|
445
|
+
if (defaults.shape === 'map') {
|
|
446
|
+
const entry = defaults.value[scope];
|
|
447
|
+
if (typeof entry === 'string' && entry.trim() !== '') {
|
|
448
|
+
const file = resolve(entry.trim());
|
|
449
|
+
if (existsSync(file)) {
|
|
450
|
+
return { file, source: 'defaults-map', detail: `${defaults.file} → canonicalClone["${scope}"]`, tried };
|
|
451
|
+
}
|
|
452
|
+
tried.push(`${defaults.file} → canonicalClone["${scope}"] = ${file} (path not present)`);
|
|
453
|
+
} else {
|
|
454
|
+
tried.push(`${defaults.file} → canonicalClone map has no entry for "${scope}"`);
|
|
455
|
+
}
|
|
456
|
+
} else if (defaults.shape === 'root') {
|
|
457
|
+
const file = join(defaults.value, 'vault', `${scope}.enc.json`);
|
|
458
|
+
if (existsSync(file)) {
|
|
459
|
+
return { file, source: 'defaults-root', detail: `${defaults.file} → canonicalClone root ${defaults.value}`, tried };
|
|
460
|
+
}
|
|
461
|
+
tried.push(`${defaults.file} → canonicalClone root ${defaults.value} → ${file} (path not present)`);
|
|
462
|
+
} else {
|
|
463
|
+
tried.push(`${defaults.file} canonicalClone: ${defaults.reason}`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const conv = discoverConventionalCanonical(home, scope);
|
|
467
|
+
if (conv.file) {
|
|
468
|
+
return { file: conv.file, source: conv.source, detail: conv.detail, tried };
|
|
469
|
+
}
|
|
470
|
+
tried.push(`convention: ${conv.reason}`);
|
|
471
|
+
|
|
472
|
+
return { file: null, tried };
|
|
473
|
+
}
|
|
474
|
+
|
|
335
475
|
/**
|
|
336
476
|
* Walk up from `cwd` for the nearest ancestor directory that sits under
|
|
337
477
|
* an "agents" path segment AND contains identity.json. Returns the file
|
|
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
|
+
}
|